mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-25 21:00:28 -05:00
Trophy fixes (#3279)
This commit is contained in:
@@ -8,7 +8,7 @@ export const STAFF_IDS = [11329, 9719, 9342, 20774, 23094];
|
||||
// hfcRed
|
||||
export const DEV_IDS = [27883];
|
||||
// hfcRed Dreamy Cafy
|
||||
export const QA_IDS: number[] = [27883, 38781, 10654];
|
||||
export const QA_IDS: number[] = [27883, 38176, 10654];
|
||||
|
||||
export const STAFF_DISCORD_IDS = [
|
||||
"138757634500067328",
|
||||
|
||||
@@ -252,6 +252,71 @@ describe("trophy list tiers", () => {
|
||||
}
|
||||
});
|
||||
|
||||
describe("existsByName", () => {
|
||||
let ownerId: number;
|
||||
let approverIds: number[];
|
||||
let organizationId: number;
|
||||
|
||||
beforeEach(async () => {
|
||||
const owner = await UserFactory.create();
|
||||
ownerId = owner.id;
|
||||
approverIds = (await UserFactory.createMany(2)).map((user) => user.id);
|
||||
organizationId = (await TournamentOrganizationFactory.create({ ownerId }))
|
||||
.id;
|
||||
});
|
||||
|
||||
test("updating a trophy keeping its name does not collide with its accepted submission", async () => {
|
||||
await TrophyFactory.createPending(
|
||||
{ name: "Winner's Cup", organizationId, submitterUserId: ownerId },
|
||||
{ approverUserIds: approverIds },
|
||||
);
|
||||
|
||||
const trophy = await db
|
||||
.selectFrom("Trophy")
|
||||
.select("id")
|
||||
.where("name", "=", "Winner's Cup")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
expect(
|
||||
await TrophyRepository.existsByName({
|
||||
name: "Winner's Cup",
|
||||
excludeTrophyId: trophy.id,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("an existing trophy's name still blocks new submissions", async () => {
|
||||
await TrophyFactory.create({ name: "Winner's Cup" });
|
||||
|
||||
expect(await TrophyRepository.existsByName({ name: "Winner's Cup" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a submission awaiting review blocks the name", async () => {
|
||||
await TrophyFactory.createPending({
|
||||
name: "Contested Cup",
|
||||
organizationId,
|
||||
submitterUserId: ownerId,
|
||||
});
|
||||
|
||||
expect(await TrophyRepository.existsByName({ name: "Contested Cup" })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("a declined submission does not block the name", async () => {
|
||||
await TrophyFactory.createPending(
|
||||
{ name: "Declined Cup", organizationId, submitterUserId: ownerId },
|
||||
{ declinedBy: { userId: approverIds[0], reason: "reason" } },
|
||||
);
|
||||
|
||||
expect(await TrophyRepository.existsByName({ name: "Declined Cup" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user deletion", () => {
|
||||
test("keeps their trophies and drops their approvals", async () => {
|
||||
const submitter = await UserFactory.create();
|
||||
|
||||
@@ -443,6 +443,10 @@ export async function findOrganizationIdById(trophyId: number) {
|
||||
return row?.organizationId ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a trophy name is taken by an existing trophy or a submission still
|
||||
* awaiting review. Accepted submissions don't count.
|
||||
*/
|
||||
export async function existsByName(args: {
|
||||
name: string;
|
||||
excludeTrophyId?: number;
|
||||
@@ -464,7 +468,22 @@ export async function existsByName(args: {
|
||||
.selectFrom("PendingTrophy")
|
||||
.select("id")
|
||||
.where("name", "=", args.name)
|
||||
.where("declinedAt", "is", null);
|
||||
.where("declinedAt", "is", null)
|
||||
.where((eb) =>
|
||||
eb(
|
||||
eb
|
||||
.selectFrom("PendingTrophyApproval")
|
||||
.select(({ fn }) => fn.countAll<number>().as("count"))
|
||||
.whereRef(
|
||||
"PendingTrophyApproval.pendingTrophyId",
|
||||
"=",
|
||||
"PendingTrophy.id",
|
||||
)
|
||||
.$asScalar(),
|
||||
"<",
|
||||
TROPHY_APPROVALS_REQUIRED,
|
||||
),
|
||||
);
|
||||
|
||||
if (args.excludeTrophyId !== undefined) {
|
||||
pendingQuery = pendingQuery.where(
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
trophyFormSchema,
|
||||
} from "../trophies-schemas";
|
||||
import {
|
||||
canAccessTrophies,
|
||||
canEditTrophy,
|
||||
canReviewTrophies,
|
||||
compressTrophyModel,
|
||||
@@ -28,9 +27,6 @@ import {
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
if (!canAccessTrophies(user)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const isJson = request.headers.get("Content-Type") === "application/json";
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { clsx } from "clsx";
|
||||
import { Ban } from "lucide-react";
|
||||
import { PicoCAD2Context, PicoCAD2Viewer } from "picocad2-web";
|
||||
import {
|
||||
PicoCAD2Context,
|
||||
PicoCAD2Viewer,
|
||||
type RenderStats,
|
||||
} from "picocad2-web";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
@@ -84,6 +88,7 @@ export function Trophy({
|
||||
disableCameraControls,
|
||||
staticOnSoftwareRendering,
|
||||
pill,
|
||||
onRenderStats,
|
||||
}: {
|
||||
model: string;
|
||||
className?: string;
|
||||
@@ -94,6 +99,7 @@ export function Trophy({
|
||||
disableCameraControls?: boolean;
|
||||
staticOnSoftwareRendering?: boolean;
|
||||
pill?: React.ReactNode;
|
||||
onRenderStats?: (stats: RenderStats) => void;
|
||||
}) {
|
||||
const ctxValue = useContext(TrophyCtx);
|
||||
const context = ctxValue?.context;
|
||||
@@ -102,6 +108,9 @@ export function Trophy({
|
||||
const viewerRef = useRef<PicoCAD2Viewer | null>(null);
|
||||
const [error, setError] = useState<boolean>(false);
|
||||
|
||||
const onRenderStatsRef = useRef(onRenderStats);
|
||||
onRenderStatsRef.current = onRenderStats;
|
||||
|
||||
const prevModelRef = useRef(model);
|
||||
if (prevModelRef.current !== model) {
|
||||
prevModelRef.current = model;
|
||||
@@ -151,8 +160,15 @@ export function Trophy({
|
||||
return;
|
||||
}
|
||||
|
||||
if (context && onRenderStats) {
|
||||
viewer.onFrame = () => {
|
||||
onRenderStatsRef.current?.({ ...context.stats });
|
||||
};
|
||||
}
|
||||
|
||||
viewer.cameraMode = "spin";
|
||||
viewer.cameraModeSpeed = 5;
|
||||
viewer.animation.setTime(0);
|
||||
viewer.startRenderLoop(false);
|
||||
|
||||
if (disableCameraControls) return;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { Color3, ExtrasOptions, ViewerSettings } from "picocad2-web";
|
||||
import type {
|
||||
Color3,
|
||||
ExtrasOptions,
|
||||
RenderStats,
|
||||
ViewerSettings,
|
||||
} from "picocad2-web";
|
||||
|
||||
export interface TrophyModelAnalysis {
|
||||
cameraTargetCentered: boolean;
|
||||
@@ -50,6 +55,28 @@ export function analyzeTrophyModel(model: string): TrophyModelAnalysis | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Animations can toggle meshes on and off, so the peak across frames
|
||||
* is what the draw call and poly metrics should show.
|
||||
*/
|
||||
export function mergePeakRenderStats(
|
||||
previous: RenderStats | null,
|
||||
frame: RenderStats,
|
||||
) {
|
||||
if (
|
||||
previous &&
|
||||
frame.drawCalls <= previous.drawCalls &&
|
||||
frame.polyCount <= previous.polyCount
|
||||
) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return {
|
||||
drawCalls: Math.max(frame.drawCalls, previous?.drawCalls ?? 0),
|
||||
polyCount: Math.max(frame.polyCount, previous?.polyCount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isCameraTargetCentered(state: ModelState) {
|
||||
const target = state.settings.camera.target;
|
||||
return target[0] === 0 && target[2] === 0;
|
||||
|
||||
@@ -3,19 +3,12 @@ import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import { TROPHY_APPROVALS_REQUIRED } from "../trophies-constants";
|
||||
import {
|
||||
canAccessTrophies,
|
||||
canEditAnyTrophy,
|
||||
canReviewTrophies,
|
||||
} from "../trophies-utils";
|
||||
import { canEditAnyTrophy, canReviewTrophies } from "../trophies-utils";
|
||||
|
||||
export type NewTrophyLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export const loader = async (_args: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
if (!canAccessTrophies(user)) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
const canReview = canReviewTrophies(user);
|
||||
|
||||
|
||||
68
app/features/trophies/routes/trophies.new.test.ts
Normal file
68
app/features/trophies/routes/trophies.new.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as TrophyFactory from "~/db/seed/factories/TrophyFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
import { decompressFromBase64 } from "~/utils/compression";
|
||||
import { wrappedAction, wrappedLoader } from "~/utils/Test";
|
||||
import { action } from "../actions/trophies.new.server";
|
||||
import {
|
||||
loader,
|
||||
type NewTrophyLoaderData,
|
||||
} from "../loaders/trophies.new.server";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
import type { trophyFormSchema } from "../trophies-schemas";
|
||||
|
||||
// remove file once feature is released
|
||||
|
||||
vi.mock("~/features/trophies/trophies-constants", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("~/features/trophies/trophies-constants")
|
||||
>()),
|
||||
TROPHIES_RELEASED: false,
|
||||
}));
|
||||
|
||||
const newTrophyLoader = wrappedLoader<NewTrophyLoaderData>({ loader });
|
||||
const submitAction = wrappedAction<typeof trophyFormSchema>({
|
||||
action,
|
||||
isJsonSubmission: true,
|
||||
});
|
||||
|
||||
describe("trophy submissions before release", () => {
|
||||
beforeEach(async () => {
|
||||
await UserFactory.createAdmin();
|
||||
await UserFactory.createRegular();
|
||||
});
|
||||
|
||||
test("a regular user can open the submission page", async () => {
|
||||
const data = await newTrophyLoader({ user: "regular" });
|
||||
|
||||
expect(data.canReview).toBe(false);
|
||||
expect(data.currentUserId).toBe(REGULAR_USER_TEST_ID);
|
||||
});
|
||||
|
||||
test("a regular user can submit a trophy", async () => {
|
||||
const organization = await TournamentOrganizationFactory.create({
|
||||
ownerId: ADMIN_ID,
|
||||
});
|
||||
|
||||
const result = await submitAction(
|
||||
{
|
||||
_action: "CREATE",
|
||||
name: "Regular Trophy",
|
||||
model: decompressFromBase64(TrophyFactory.MODELS[0]) ?? "",
|
||||
organizationId: organization.id,
|
||||
description: null,
|
||||
},
|
||||
{ user: "regular" },
|
||||
);
|
||||
|
||||
expect(result).toBe(null);
|
||||
|
||||
const pending =
|
||||
await TrophyRepository.pendingBySubmitter(REGULAR_USER_TEST_ID);
|
||||
expect(pending.length).toBe(1);
|
||||
expect(pending[0].name).toBe("Regular Trophy");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import clsx from "clsx";
|
||||
import { Check, Clipboard, Dot, Trash2, TriangleAlert, X } from "lucide-react";
|
||||
import type { RenderStats } from "picocad2-web";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -41,6 +42,7 @@ import { action } from "../actions/trophies.new.server";
|
||||
import { Trophy, TrophyContextProvider } from "../components/Trophy";
|
||||
import {
|
||||
analyzeTrophyModel,
|
||||
mergePeakRenderStats,
|
||||
type TrophyModelAnalysis,
|
||||
} from "../core/model-analysis";
|
||||
import {
|
||||
@@ -356,9 +358,24 @@ function ModelField({
|
||||
}) {
|
||||
const { t } = useTranslation(["forms", "trophies"]);
|
||||
const [preview, setPreview] = React.useState(() => buildModelPreview(value));
|
||||
const [peak, setPeak] = React.useState<{
|
||||
model: string;
|
||||
stats: RenderStats;
|
||||
} | null>(null);
|
||||
|
||||
useDebounce(() => setPreview(buildModelPreview(value)), 500, [value]);
|
||||
|
||||
const reportRenderStats = (stats: RenderStats) => {
|
||||
const model = preview.compressedModel;
|
||||
setPeak((prev) => {
|
||||
const previousStats = prev?.model === model ? prev.stats : null;
|
||||
const merged = mergePeakRenderStats(previousStats, stats);
|
||||
return merged === previousStats ? prev : { model, stats: merged };
|
||||
});
|
||||
};
|
||||
|
||||
const peakStats = peak?.model === preview.compressedModel ? peak.stats : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label htmlFor={name} required>
|
||||
@@ -404,13 +421,14 @@ function ModelField({
|
||||
<Trophy
|
||||
model={preview.compressedModel}
|
||||
className={styles.trophyPreview}
|
||||
onRenderStats={reportRenderStats}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TrophyContextProvider>
|
||||
) : null}
|
||||
<ModelSpecs analysis={preview.analysis} />
|
||||
<ModelSpecs analysis={preview.analysis} peakStats={peakStats} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -424,7 +442,13 @@ function buildModelPreview(model: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
|
||||
function ModelSpecs({
|
||||
analysis,
|
||||
peakStats,
|
||||
}: {
|
||||
analysis: TrophyModelAnalysis | null;
|
||||
peakStats: RenderStats | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["trophies"]);
|
||||
|
||||
const enforcedStatus = (passes: boolean) =>
|
||||
@@ -432,6 +456,9 @@ function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
|
||||
const recommendedStatus = (withinLimit: boolean) =>
|
||||
analysis ? (withinLimit ? "pass" : "warn") : null;
|
||||
|
||||
const drawCalls = peakStats?.drawCalls ?? analysis?.drawCalls ?? 0;
|
||||
const polyCount = peakStats?.polyCount ?? analysis?.polyCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.modelSpecs}>
|
||||
<div>
|
||||
@@ -448,6 +475,8 @@ function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
|
||||
<SpecItem>{t("trophies:new.specs.centered")}</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.zoom")}</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.angles")}</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.noStandaloneFlag")}</SpecItem>
|
||||
<SpecItem>{t("trophies:new.specs.noTrademarked")}</SpecItem>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -457,13 +486,12 @@ function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
|
||||
<ul className={styles.specList}>
|
||||
<SpecItem
|
||||
status={recommendedStatus(
|
||||
!!analysis &&
|
||||
analysis.drawCalls <= TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
drawCalls <= TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
)}
|
||||
detail={
|
||||
analysis
|
||||
? t("trophies:new.specs.currentValue", {
|
||||
value: analysis.drawCalls,
|
||||
value: drawCalls,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
@@ -474,13 +502,12 @@ function ModelSpecs({ analysis }: { analysis: TrophyModelAnalysis | null }) {
|
||||
</SpecItem>
|
||||
<SpecItem
|
||||
status={recommendedStatus(
|
||||
!!analysis &&
|
||||
analysis.polyCount <= TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
polyCount <= TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
)}
|
||||
detail={
|
||||
analysis
|
||||
? t("trophies:new.specs.currentValue", {
|
||||
value: analysis.polyCount,
|
||||
value: polyCount,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
@@ -640,6 +667,13 @@ function TrophyListRow({
|
||||
analyzeTrophyModel(decompressTrophyModel(pending.model) ?? ""),
|
||||
);
|
||||
|
||||
const [peakStats, setPeakStats] = React.useState<RenderStats | null>(null);
|
||||
const reportRenderStats = (stats: RenderStats) =>
|
||||
setPeakStats((prev) => mergePeakRenderStats(prev, stats));
|
||||
|
||||
const drawCalls = peakStats?.drawCalls ?? analysis?.drawCalls ?? 0;
|
||||
const polyCount = peakStats?.polyCount ?? analysis?.polyCount ?? 0;
|
||||
|
||||
return (
|
||||
<div className={styles.pendingItem} data-testid="pending-trophy">
|
||||
<button
|
||||
@@ -655,7 +689,11 @@ function TrophyListRow({
|
||||
onClose={() => setPreviewOpen(false)}
|
||||
showCloseButton
|
||||
>
|
||||
<Trophy model={pending.model} className={styles.trophyPreview} />
|
||||
<Trophy
|
||||
model={pending.model}
|
||||
className={styles.trophyPreview}
|
||||
onRenderStats={reportRenderStats}
|
||||
/>
|
||||
</SendouDialog>
|
||||
<div className={styles.pendingMain}>
|
||||
<div className={styles.pendingHeader}>
|
||||
@@ -707,21 +745,21 @@ function TrophyListRow({
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.drawCalls > TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
drawCalls > TROPHY_MODEL_RECOMMENDED_MAX_DRAW_CALLS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.drawCalls", {
|
||||
value: analysis.drawCalls,
|
||||
value: drawCalls,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
className={clsx({
|
||||
[styles.pendingSpecsWarn]:
|
||||
analysis.polyCount > TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
polyCount > TROPHY_MODEL_RECOMMENDED_MAX_POLYS,
|
||||
})}
|
||||
>
|
||||
{t("trophies:new.specs.stats.polys", {
|
||||
value: analysis.polyCount,
|
||||
value: polyCount,
|
||||
})}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -13,6 +13,12 @@ vi.mock("~/utils/compression", () => ({
|
||||
decompressFromBase64: vi.fn((_compressed: string): string | null => null),
|
||||
}));
|
||||
|
||||
// pinned to unreleased so the role gating stays covered whatever the real flag says
|
||||
vi.mock("./trophies-constants", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./trophies-constants")>()),
|
||||
TROPHIES_RELEASED: false,
|
||||
}));
|
||||
|
||||
const decompressMock = vi.mocked(decompressFromBase64);
|
||||
|
||||
const ENTRY_CHARS = 3_500_000;
|
||||
|
||||
@@ -32,6 +32,11 @@ test.describe("Trophies", () => {
|
||||
const userPage = new UserPage(page);
|
||||
await userPage.goto(ADMIN_DISCORD_ID);
|
||||
await isNotVisible(page.getByTestId("trophy-display"));
|
||||
|
||||
// remove once feature is released
|
||||
const newTrophy = new NewTrophyPage(page);
|
||||
await newTrophy.goto();
|
||||
await expect(newTrophy.locators.agreeToTermsButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("shows trophy wins via user page trophy display", async ({
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "Model must be visually centered on all axes",
|
||||
"new.specs.zoom": "Zoom must be adjusted so the model fills out the viewport as much as possible without touching any edges",
|
||||
"new.specs.angles": "Model must be viewable from all angles",
|
||||
"new.specs.noStandaloneFlag": "Model can't be a standalone flag",
|
||||
"new.specs.noTrademarked": "Do not depict trademarked material",
|
||||
"new.specs.background": "Background color must be the alpha color",
|
||||
"new.specs.drawCalls": "Max {{value}} draw calls",
|
||||
"new.specs.polys": "Max {{value}} polys",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
"new.specs.centered": "",
|
||||
"new.specs.zoom": "",
|
||||
"new.specs.angles": "",
|
||||
"new.specs.noStandaloneFlag": "",
|
||||
"new.specs.noTrademarked": "",
|
||||
"new.specs.background": "",
|
||||
"new.specs.drawCalls": "",
|
||||
"new.specs.polys": "",
|
||||
|
||||
Reference in New Issue
Block a user