Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -949,6 +949,14 @@ export type SearchDestinationSplit = {
rest_destination: Zone;
};

// CR 107.1a/b: the engine-published contract for a choice whose answer the
// player types instead of picking from `options`. Mirrored from Rust
// `ability::FreeEntry`. `min`/`max` are INCLUSIVE and are the same bounds
// `ChoiceType::accepts_free_entry_answer` enforces — the client renders and
// bounds its input from these values and must never restate them, or it becomes
// a second authority that can reject what the engine accepts.
export type FreeEntry = { kind: "Number"; min: number; max: number };

// ── Game Object ──────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -1721,7 +1729,7 @@ export type WaitingFor =
| { type: "TriggerTargetSelection"; data: { player: PlayerId; trigger_controller?: PlayerId; trigger_event?: GameEvent; trigger_events?: GameEvent[]; target_slots: TargetSelectionSlot[]; mode_labels?: (string | null)[]; target_constraints?: TargetSelectionConstraint[]; selection: TargetSelectionProgress; source_id?: ObjectId; description?: string } }
| { type: "BetweenGamesSideboard"; data: { player: PlayerId; game_number: number; score: MatchScore; min_main_deck_size: number; max_sideboard_size: number | null } }
| { type: "BetweenGamesChoosePlayDraw"; data: { player: PlayerId; game_number: number; score: MatchScore } }
| { type: "NamedChoice"; data: { player: PlayerId; choice_type: string | Record<string, unknown>; options: string[]; source?: { prompt: { identity: unknown; controller: PlayerId; display_name: string }; binding: "ResolutionContext" | "ExactObjectAndResolution" }; persist_player?: PlayerId } }
| { type: "NamedChoice"; data: { player: PlayerId; choice_type: string | Record<string, unknown>; options: string[]; source?: { prompt: { identity: unknown; controller: PlayerId; display_name: string }; binding: "ResolutionContext" | "ExactObjectAndResolution" }; persist_player?: PlayerId; free_entry?: FreeEntry } }
| { type: "OpponentGuess"; data: { player: PlayerId; options: string[]; choice_type: string | Record<string, unknown>; source: { prompt: { identity: unknown; controller: PlayerId; display_name: string } }; proposition_truth?: boolean } }
| { type: "SpellbookDraft"; data: { player: PlayerId; source_id: ObjectId; options: string[]; destination: Zone; tapped?: boolean } }
| { type: "DamageSourceChoice"; data: { player: PlayerId; source_filter: TargetFilter; options: ObjectId[] } }
Expand Down
68 changes: 67 additions & 1 deletion client/src/components/modal/NamedChoiceModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
getPlayerDisplayName,
useMultiplayerStore,
} from "../../stores/multiplayerStore.ts";
import type { PlayerId, WaitingFor } from "../../adapter/types.ts";
import type { FreeEntry, PlayerId, WaitingFor } from "../../adapter/types.ts";

type OptionChoice = Extract<
WaitingFor,
Expand Down Expand Up @@ -54,9 +54,75 @@ export function NamedChoiceModal({ data }: { data: OptionChoice["data"] }) {
if (typeKey === "CardName") {
return <CardNameSearch />;
}
// CR 107.1a/b: the engine publishes a free-entry contract when the answer is
// typed rather than picked from `options`. Its PRESENCE — not any reading of
// `choice_type`'s serialized shape — selects the numeric form; an enumerated
// choice carries no contract and keeps its grid.
const freeEntry = "free_entry" in data ? data.free_entry : undefined;
if (freeEntry?.kind === "Number") {
return <NumberEntry contract={freeEntry} />;
}
return <ButtonGrid data={data} typeKey={typeKey} />;
}

/** CR 107.1a/b: free-entry numeric prompt, rendered and bounded entirely from
* the engine's published contract.
*
* The engine validates the submitted answer authoritatively
* (`ChoiceType::accepts_free_entry_answer`) against the same bounds it
* published here, so this check can only ever agree with it. Deriving the
* bounds locally instead — from the choice type's shape, or from a hard-coded
* numeric ceiling — would make the client a second authority, free to reject a
* value the engine accepts and to drift when the engine's domain changes. */
function NumberEntry({ contract }: { contract: FreeEntry }) {
const { min, max } = contract;
const { t } = useTranslation("game");
const dispatch = useGameDispatch();
const [value, setValue] = useState(String(min));
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);

const parsed = /^\d+$/.test(value.trim()) ? Number(value.trim()) : null;
const valid =
parsed !== null &&
Number.isSafeInteger(parsed) &&
parsed >= min &&
parsed <= max;

const confirm = useCallback(() => {
if (valid) {
dispatch({ type: "ChooseOption", data: { choice: String(parsed) } });
}
}, [dispatch, parsed, valid]);

return (
<ChoiceOverlay
title={t("namedChoice.title.numberRange")}
subtitle={t("namedChoice.numberSubtitle", { min })}
footer={<ConfirmButton onClick={confirm} disabled={!valid} />}
>
<input
ref={inputRef}
type="text"
inputMode="numeric"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && valid) {
e.preventDefault();
confirm();
}
}}
className="w-40 rounded-lg border-2 border-gray-600 bg-gray-900/90 px-4 py-3 text-center text-xl text-white outline-none transition focus:border-cyan-400"
/>
</ChoiceOverlay>
);
}

function CardNameSearch() {
const { t } = useTranslation("game");
const dispatch = useGameDispatch();
Expand Down
71 changes: 71 additions & 0 deletions client/src/components/modal/__tests__/NamedChoiceModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,75 @@ describe("NamedChoiceModal", () => {
data: { choice: "Blue" },
});
});

// CR 107.1a/b. The engine publishes `free_entry` for a choice whose answer is
// typed rather than picked, and enforces exactly those bounds. These pin that
// the modal RENDERS the contract rather than re-deriving one: the numeric form
// is selected by the contract's presence, and the bounds it enforces are the
// ones it was handed.
describe("free-entry number contract", () => {
const numberChoice = (max: number): NamedChoiceData => ({
player: 0,
choice_type: { NumberRange: { min: 2 } },
options: [],
free_entry: { kind: "Number", min: 2, max },
});

it("submits a value inside the published range", () => {
render(<NamedChoiceModal data={numberChoice(2147483647)} />);

fireEvent.change(screen.getByRole("textbox"), {
target: { value: "1000000" },
});
fireEvent.click(screen.getByRole("button", { name: "Confirm" }));

expect(dispatchMock).toHaveBeenCalledWith({
type: "ChooseOption",
data: { choice: "1000000" },
});
});

// The bound comes from the contract, not from a constant in the component.
// A modal that hard-coded the i32 ceiling would accept 500 here.
it("refuses a value past the published maximum, whatever that maximum is", () => {
render(<NamedChoiceModal data={numberChoice(99)} />);

fireEvent.change(screen.getByRole("textbox"), {
target: { value: "500" },
});
expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled();

// ...and the same component accepts a value the contract does allow, so
// the assertion above is about the bound and not about the input being
// inert.
fireEvent.change(screen.getByRole("textbox"), {
target: { value: "99" },
});
expect(screen.getByRole("button", { name: "Confirm" })).toBeEnabled();
});

it("refuses a value below the published minimum", () => {
render(<NamedChoiceModal data={numberChoice(99)} />);

fireEvent.change(screen.getByRole("textbox"), {
target: { value: "1" },
});
expect(screen.getByRole("button", { name: "Confirm" })).toBeDisabled();
});

// Without a contract there is nothing to type into: the choice is enumerated
// and keeps its button grid, even though its choice type is still a range.
it("renders the option grid when the engine publishes no contract", () => {
const data: NamedChoiceData = {
player: 0,
choice_type: { NumberRange: { min: 0, max: 2 } },
options: ["0", "1", "2"],
};

render(<NamedChoiceModal data={data} />);

expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "1" })).toBeInTheDocument();
});
});
});
130 changes: 130 additions & 0 deletions client/src/i18n/__tests__/localeParity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";

import { describe, expect, it } from "vitest";

/**
* Every locale must carry the same keys as the English source, with the same
* interpolation placeholders.
*
* The test suite renders in English only (`test-setup.ts` loads `en`), so a key
* added to `en` and forgotten elsewhere, or a translation whose `{{placeholder}}`
* was dropped or renamed, produces no failing test — it produces a raw key or a
* missing value in front of a player who does not read English, which nobody
* running the suite will see. This closes that gap.
*
* The placeholder half is the one that catches real damage: a translation that
* drops `{{min}}` still renders as fluent prose, so it reads as correct while
* silently omitting the value the sentence exists to communicate.
*/

const LOCALES_DIR = join(__dirname, "..", "locales");
const SOURCE = "en";

/**
* Known pre-existing divergences, each with the reason it is tolerated.
*
* This is a list of DEFECTS, not of exemptions: an entry here means the string
* is wrong and has not been fixed yet, so keep it short and remove entries as
* they are fixed rather than adding to it.
*/
const KNOWN_PLACEHOLDER_GAPS: ReadonlyArray<{
ns: string;
key: string;
why: string;
}> = [
{
ns: "draft.json",
key: "intro.quick.step1",
why:
"All six translations hard-code the default '3 packs of 14 cards' instead " +
"of interpolating {{packCount}}/{{cardsPerPack}}, so a non-default draft " +
"shows wrong numbers. Pre-existing; tracked separately.",
},
];

type Flat = Record<string, unknown>;

function flatten(value: unknown, prefix = "", out: Flat = {}): Flat {
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
flatten(v, prefix ? `${prefix}.${k}` : k, out);
}
} else {
out[prefix] = value;
}
return out;
}

function load(locale: string, ns: string): Flat {
return flatten(JSON.parse(readFileSync(join(LOCALES_DIR, locale, ns), "utf8")));
}

/** The `{{name}}` placeholders a string interpolates, sorted for comparison. */
function placeholders(value: unknown): string[] {
if (typeof value !== "string") return [];
return [...value.matchAll(/\{\{\s*([\w.]+)/g)].map((m) => m[1]).sort();
}

const namespaces = readdirSync(join(LOCALES_DIR, SOURCE)).filter((f) =>
f.endsWith(".json"),
);
const locales = readdirSync(LOCALES_DIR).filter((d) => d !== SOURCE);

const isKnownGap = (ns: string, key: string) =>
KNOWN_PLACEHOLDER_GAPS.some((g) => g.ns === ns && g.key === key);

describe("locale parity", () => {
// Guards the guard: if the layout changes and these come back empty, every
// assertion below passes over nothing.
it("discovers the locales and namespaces it is meant to check", () => {
expect(namespaces.length).toBeGreaterThan(0);
expect(locales.length).toBeGreaterThan(0);
expect(locales).toContain("de");
});

describe.each(locales)("%s", (locale) => {
it.each(namespaces)("%s has exactly the English key set", (ns) => {
const source = load(SOURCE, ns);
const target = load(locale, ns);

expect(Object.keys(source).filter((k) => !(k in target))).toEqual([]);
// Extra keys are dead weight: nothing reads them, and they hide the fact
// that the English source dropped a string.
expect(Object.keys(target).filter((k) => !(k in source))).toEqual([]);
});

it.each(namespaces)("%s interpolates the same placeholders", (ns) => {
const source = load(SOURCE, ns);
const target = load(locale, ns);

const diverged = Object.keys(source)
.filter((k) => k in target && !isKnownGap(ns, k))
.filter(
(k) =>
placeholders(source[k]).join() !== placeholders(target[k]).join(),
)
.map(
(k) =>
`${k}: en=[${placeholders(source[k])}] ${locale}=[${placeholders(target[k])}]`,
);

expect(diverged).toEqual([]);
});
});

// Without this, a fixed defect could sit in the list forever, quietly
// exempting a key that no longer needs it.
it("has no stale entries in the known-gap list", () => {
const stale = KNOWN_PLACEHOLDER_GAPS.filter(({ ns, key }) => {
const source = load(SOURCE, ns);
return locales.every(
(locale) =>
placeholders(source[key]).join() ===
placeholders(load(locale, ns)[key]).join(),
);
}).map(({ ns, key }) => `${ns}:${key}`);

expect(stale).toEqual([]);
});
});
5 changes: 5 additions & 0 deletions client/src/i18n/locales/de/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Schließen",
"closeNamed": "{{name}} schließen"
},
"quantityRef": {
"highestNumber": "die höchste Zahl",
"lowestNumber": "die niedrigste Zahl",
"chosenNumber": "die gewählte Zahl"
},
"scryOutcome": {
"title": "Spähen abgeschlossen",
"you": "Du",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -2051,6 +2051,7 @@
"searchPlaceholder": "Nach Name suchen...",
"noCardsFound": "Keine Karten gefunden",
"buttonSubtitle": "Wähle eine Option",
"numberSubtitle": "Gib eine ganze Zahl größer oder gleich {{min}} ein",
"filterPlaceholder": "Optionen filtern...",
"noOptionsMatch": "Keine Option passt"
},
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Close",
"closeNamed": "Close {{name}}"
},
"quantityRef": {
"highestNumber": "the highest number",
"lowestNumber": "the lowest number",
"chosenNumber": "the chosen number"
},
"scryOutcome": {
"title": "Scry complete",
"you": "You",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/en/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -2095,6 +2095,7 @@
"searchPlaceholder": "Search by name...",
"noCardsFound": "No cards found",
"buttonSubtitle": "Select one option",
"numberSubtitle": "Enter a whole number {{min}} or greater",
"filterPlaceholder": "Filter options...",
"noOptionsMatch": "No options match"
},
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/es/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Cerrar",
"closeNamed": "Cerrar {{name}}"
},
"quantityRef": {
"highestNumber": "el número más alto",
"lowestNumber": "el número más bajo",
"chosenNumber": "el número elegido"
},
"scryOutcome": {
"title": "Adivinación completada",
"you": "Tú",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/es/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -2051,6 +2051,7 @@
"searchPlaceholder": "Buscar por nombre...",
"noCardsFound": "No se encontraron cartas",
"buttonSubtitle": "Selecciona una opción",
"numberSubtitle": "Introduce un número entero mayor o igual que {{min}}",
"filterPlaceholder": "Filtrar opciones...",
"noOptionsMatch": "Ninguna opción coincide"
},
Expand Down
5 changes: 5 additions & 0 deletions client/src/i18n/locales/fr/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
"close": "Fermer",
"closeNamed": "Fermer {{name}}"
},
"quantityRef": {
"highestNumber": "le nombre le plus élevé",
"lowestNumber": "le nombre le plus bas",
"chosenNumber": "le nombre choisi"
},
"scryOutcome": {
"title": "Regard terminé",
"you": "Vous",
Expand Down
1 change: 1 addition & 0 deletions client/src/i18n/locales/fr/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -2051,6 +2051,7 @@
"searchPlaceholder": "Rechercher par nom...",
"noCardsFound": "Aucune carte trouvée",
"buttonSubtitle": "Sélectionnez une option",
"numberSubtitle": "Entrez un nombre entier supérieur ou égal à {{min}}",
"filterPlaceholder": "Filtrer les options...",
"noOptionsMatch": "Aucune option ne correspond"
},
Expand Down
Loading
Loading