Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
16 changes: 15 additions & 1 deletion client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2137,6 +2137,7 @@ export type DebugAction =
attach_to?: AttachTarget;
run_etb: boolean;
nonlegendary: boolean;
count: number;
};
}
| { type: "RemoveObject"; data: { object_id: ObjectId } }
Expand Down Expand Up @@ -2169,11 +2170,12 @@ export type DebugAction =
data: {
request: DebugTokenRequest;
run_etb: boolean;
count: number;
};
}
| {
type: "CreateTokenCopy";
data: { source_id: ObjectId; owner: PlayerId; nonlegendary: boolean };
data: { source_id: ObjectId; owner: PlayerId; nonlegendary: boolean; count: number };
};

// CR 117.3d: priority-yield preference types, mirroring the engine's
Expand Down Expand Up @@ -2462,6 +2464,18 @@ export type GameEvent =
| { type: "PermanentSacrificed"; data: { object_id: ObjectId; player_id: PlayerId } }
| { type: "ArmyAmassed"; data: { object_id: ObjectId; source_id: ObjectId; controller: PlayerId } }
| { type: "EffectResolved"; data: { kind: string; source_id: ObjectId } }
// CR 701.22a: the engine records only public scry placement counts, never
// card identities, so presentation can show the completed outcome safely.
| {
type: "PlayerPerformedAction";
data: {
player_id: PlayerId;
action: string;
look_count?: number;
scry_bottom_count?: number;
scry_top_count?: number;
};
}
Comment on lines +2469 to +2478

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: List PlayerActionKind variants and every emission site.
set -euo pipefail

fd -e rs . crates/engine/src --exec rg -nP -C2 '\benum\s+PlayerActionKind\b' {} +
rg -nP --type=rust -C2 '\bPlayerActionKind::[A-Za-z]+' crates
# Confirm how the client currently narrows the field.
rg -nP --type=ts -C3 'PlayerPerformedAction' client/src

Repository: phase-rs/phase

Length of output: 24702


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- Locate relevant files ----"
git ls-files | rg '(^|/)types\.rs$|client/src/adapter/types\.ts$|engine\.rs$' | sed -n '1,120p'

echo
echo "---- PlayerActionKind enum ----"
rg -n -C 10 'pub enum PlayerActionKind|enum PlayerActionKind' .

echo
echo "---- PlayerPerformedAction emissions ----"
rg -n -C 4 'PlayerPerformedAction|ActionKind|player_action|scry_top_count|scry_bottom_count|look_count|type: PlayerActionKind' crates || true

echo
echo "---- Client types occurrence ----"
rg -n -C 8 'type: "PlayerPerformedAction"|PlayerPerformedAction' client/src || true

echo
echo "---- Client narrow usages ----"
python3 - <<'PY'
from pathlib import Path
import re
root=Path('client/src')
for p in root.rglob('*'):
    if p.suffix not in {'.ts','.tsx'}:
        continue
    text=p.read_text(errors='ignore')
    if 'PlayerPerformedAction' in text:
        print(f'--- {p} ---')
        lines=text.splitlines()
        for i,l in enumerate(lines,1):
            if 'PlayerPerformedAction' in l:
                start=max(1,i-6); end=min(len(lines),i+18)
                for j in range(start,end+1):
                    print(f'{j:5}: {lines[j-1]}')
PY

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "---- Engine PlayerPerformedAction/PlayerActionKind occurrences (focused) ----"
rg -n -C 3 'GameEvent::PlayerPerformedAction|player_actions_this_way|player_actions_this_turn|PlayerActionKind::|PlayerActionKind\|' crates/engine/src crates/engine/tests --glob '*.rs' | \
python3 - <<'PY'
import sys, subprocess, re
text = subprocess.check_output(['grep','-n','-C3','GameEvent::PlayerPerformedAction|player_actions_this_way|player_actions_this_turn|PlayerActionKind::|PlayerActionKind\\|','crates/engine/src','crates/engine/tests','-R','--include','*.rs','--color=never'], text=True)
lines = text.splitlines()
print('\n'.join(lines[:400]))
PY

echo
echo "---- Client TypeScript PlayerPerformedAction occurrences with line numbers ----"
rg -n -C 6 'PlayerPerformedAction|type: "PlayerPerformedAction"|action: string' client/src --glob '*.ts' --glob '*.tsx' || true

echo
echo "---- TypeScript union mirror names nearby ----"
rg -n -C 8 'type .* = .*\\|;|enum .* \\{|union|DungeonId|PlanarDieFace|GameEvent' client/src/adapter/types.ts | sed -n '1,260p'

Repository: phase-rs/phase

Length of output: 44114


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Extract only PlayerActionKind declarations and GameEvent construction sites for PlayerPerformedAction.
python3 - <<'PY'
import pathlib,time
for path in pathlib.Path('crates/engine/src').rglob('*.rs') + pathlib.Path('crates/engine/tests').rglob('*.rs'):
    try:
        s=path.read_text(errors='ignore')
    except Exception:
        continue
    if 'pub enum PlayerActionKind' in s or 'enum PlayerActionKind' in s:
        print(f'--- {path} enum ---')
        for i,l in enumerate(s.splitlines(),1):
            if 'PlayerActionKind' in l: print(f'{i}: {l}')
    if 'GameEvent::PlayerPerformedAction' in s:
        print(f'--- {path} constructions ---')
        lines=s.splitlines()
        for i,l in enumerate(lines,1):
            if 'GameEvent::PlayerPerformedAction' in l:
                start=max(1,i-4); end=min(len(lines),i+10)
                for j in range(start,end+1):
                    print(f'{j}: {lines[j-1]}')
PY

echo
python3 - <<'PY'
import pathlib
for path in pathlib.Path('client/src').rglob('*'):
    if path.suffix not in {'.ts','.tsx'}:
        continue
    text=path.read_text(errors='ignore')
    if 'PlayerPerformedAction' in text:
        print(f'--- {path} ---')
        lines=text.splitlines()
        for i,l in enumerate(lines,1):
            if 'PlayerPerformedAction' in l:
                start=max(1,i-8); end=min(len(lines),i+12)
                for j in range(start,end+1):
                    print(f'{j}: {lines[j-1]}')
PY

Repository: phase-rs/phase

Length of output: 299


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import pathlib
rs = pathlib.PurePosixPath('crates/engine/src/**/*.rs')
tests = pathlib.PurePosixPath('crates/engine/tests/**/*.rs')
for path in list(pathlib.Path('crates/engine/src').rglob('*.rs')) + list(pathlib.Path('crates/engine/tests').rglob('*.rs')):
    try:
        s = path.read_text(errors='ignore')
    except Exception:
        continue
    if 'pub enum PlayerActionKind' in s or 'enum PlayerActionKind' in s:
        print(f'--- {path} enum ---')
        for i, l in enumerate(s.splitlines(), 1):
            if 'PlayerActionKind' in l:
                print(f'{i}: {l}')
    if 'GameEvent::PlayerPerformedAction' in s:
        lines = s.splitlines()
        print(f'--- {path} constructions ---')
        for i, l in enumerate(lines, 1):
            if 'GameEvent::PlayerPerformedAction' in l:
                start = max(1, i - 4)
                end = min(len(lines), i + 10)
                for j in range(start, end + 1):
                    print(f'{j}: {lines[j-1]}')
PY

echo

python3 - <<'PY'
import pathlib
for path in pathlib.Path('client/src').rglob('*'):
    if path.suffix not in {'.ts', '.tsx'}:
        continue
    text = path.read_text(errors='ignore')
    if 'PlayerPerformedAction' in text:
        print(f'--- {path} ---')
        lines = text.splitlines()
        for i, l in enumerate(lines, 1):
            if 'PlayerPerformedAction' in l:
                start = max(1, i - 8)
                end = min(len(lines), i + 12)
                for j in range(start, end + 1):
                    print(f'{j}: {lines[j-1]}')
PY

Repository: phase-rs/phase

Length of output: 50370


Mirror PlayerActionKind with a closed string-literal union.

GameEvent::PlayerPerformedAction is a Rust PlayerActionKind; its variants are emitted by the engine (Scry, Surveil, Investigate, CollectEvidence, SearchedLibrary, ShuffledLibrary, Proliferate, etc.). In client/src/adapter/types.ts, widening it to string makes client narrowing incomplete and weakens the transport contract against future variant changes. Keep the existing optional count fields, but make non-scry/non-surveil actions handle missing counts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/adapter/types.ts` around lines 2469 - 2478, Replace the
PlayerPerformedAction data.action string type with a closed string-literal union
matching all PlayerActionKind variants emitted by the engine, including Scry,
Surveil, Investigate, CollectEvidence, SearchedLibrary, ShuffledLibrary, and
Proliferate. Keep look_count, scry_bottom_count, and scry_top_count optional,
and ensure non-scry/non-surveil variants remain valid when those counts are
absent.

| { type: "AttackersDeclared"; data: { attacker_ids: ObjectId[]; defending_player: PlayerId; attacks?: [ObjectId, AttackTarget][] } }
| { type: "BlockersDeclared"; data: { assignments: [ObjectId, ObjectId][] } }
| { type: "BecomesTarget"; data: { target: TargetRef; source_id: ObjectId } }
Expand Down
5 changes: 4 additions & 1 deletion client/src/adapter/ws-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,9 @@ export class NativeEngineVersionMismatchError extends Error {
* `crates/server-core/src/protocol.rs`. Bump in lockstep when either side
* adds, removes, renames, or changes the type of a protocol variant field.
*
* 25 — DebugCardEntries added a serialized, private resolution frame for
* multi-card sandbox battlefield entries that pause for replacement or
* as-enters choices. Old peers cannot deserialize that GameState shape.
* 24 — DerivedViews.unbounded_families carries the engine-owned per-seat family
* collapse state behind each ∞ badge. A CAPABILITY bump, not a parse bump:
* the field is serde-optional, but this client deleted its row-flag
Expand All @@ -228,7 +231,7 @@ export class NativeEngineVersionMismatchError extends Error {
* into a MulliganDecisionPhase::BottomCards sub-phase on
* WaitingFor::MulliganDecision.
*/
export const PROTOCOL_VERSION = 24;
export const PROTOCOL_VERSION = 25;

/**
* Lowest server protocol version this client will accept in the handshake.
Expand Down
52 changes: 52 additions & 0 deletions client/src/components/animation/ScryOutcomeOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useTranslation } from "react-i18next";

import { usePlayerId } from "../../hooks/usePlayerId.ts";
import { getOpponentDisplayName } from "../../stores/multiplayerStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";

/**
* Brief, board-visible confirmation of a completed scry. The engine event
* supplies the public placement counts; this component only presents them.
*/
export function ScryOutcomeOverlay() {
const outcome = useUiStore((state) => state.scryOutcome);
const playerId = usePlayerId();
const shouldReduceMotion = useReducedMotion();
const { t } = useTranslation();

const player = outcome
? outcome.playerId === playerId
? t("scryOutcome.you")
: getOpponentDisplayName(outcome.playerId)
: "";

return (
<AnimatePresence>
{outcome && (
<motion.div
className="pointer-events-none fixed top-[max(env(safe-area-inset-top),0.75rem)] left-1/2 z-[52] -translate-x-1/2"
role="status"
aria-live="polite"
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.98 }}
transition={{ duration: shouldReduceMotion ? 0.1 : 0.22 }}
>
<div className="min-w-56 rounded-xl border border-sky-300/45 bg-slate-950/90 px-4 py-3 text-center shadow-[0_0_28px_rgba(56,189,248,0.24)] backdrop-blur-md">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-sky-200">
{t("scryOutcome.title")}
</p>
<p className="mt-1 text-sm font-medium text-slate-100" data-testid="scry-outcome">
{t("scryOutcome.result", {
player,
top: outcome.topCount,
bottom: outcome.bottomCount,
})}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { useUiStore } from "../../../stores/uiStore.ts";
import { ScryOutcomeOverlay } from "../ScryOutcomeOverlay.tsx";

beforeEach(() => {
useUiStore.getState().resetScryOutcome();
});

afterEach(() => {
cleanup();
useUiStore.getState().resetScryOutcome();
});

describe("ScryOutcomeOverlay", () => {
it("shows the public top and bottom placement outcome", () => {
useUiStore.setState({ scryOutcome: { playerId: 1, topCount: 1, bottomCount: 2 } });

render(<ScryOutcomeOverlay />);

expect(screen.getByText("Scry complete")).toBeInTheDocument();
expect(screen.getByTestId("scry-outcome")).toHaveTextContent("Opp 2 — 1 on top · 2 on bottom");
});

it("renders nothing when there is no completed scry outcome", () => {
const { container } = render(<ScryOutcomeOverlay />);

expect(container).toBeEmptyDOMElement();
});
});
33 changes: 17 additions & 16 deletions client/src/components/chrome/DebugCardContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ObjectId,
Zone,
} from "../../adapter/types";
import { formatCounterType } from "../../viewmodel/cardProps";
import { useGameStore } from "../../stores/gameStore";
import { useUiStore } from "../../stores/uiStore";
import { useGameDispatch } from "../../hooks/useGameDispatch";
Expand Down Expand Up @@ -145,10 +146,6 @@ function DebugCardContextMenuInner({

const onBattlefield = obj.zone === "Battlefield";
const isCreature = obj.card_types?.core_types?.includes("Creature") ?? false;
const isPlaneswalker = obj.card_types?.core_types?.includes("Planeswalker") ?? false;
const isClass = obj.card_types?.subtypes?.includes("Class") ?? false;
const isSaga = obj.card_types?.subtypes?.includes("Saga") ?? false;
const hasLoreCounters = isClass || isSaga;
const hasSummoningSickness = obj.has_summoning_sickness ?? false;
const currentKeywords = obj.keywords ?? [];

Expand Down Expand Up @@ -248,18 +245,22 @@ function DebugCardContextMenuInner({
{/* Counter actions */}
{onBattlefield && (
<div className="border-b border-gray-800 py-0.5">
{isCreature && (
<>
<CounterRow label="+1/+1" objectId={objectId} counterType="P1P1" current={obj.counters?.P1P1 ?? 0} onDispatch={dispatchDebugKeepOpen} />
<CounterRow label="-1/-1" objectId={objectId} counterType="M1M1" current={obj.counters?.M1M1 ?? 0} onDispatch={dispatchDebugKeepOpen} />
</>
)}
{isPlaneswalker && (
<CounterRow label="Loyalty" objectId={objectId} counterType="loyalty" current={obj.counters?.loyalty ?? 0} onDispatch={dispatchDebugKeepOpen} />
)}
{hasLoreCounters && (
<CounterRow label="Lore" objectId={objectId} counterType="lore" current={obj.counters?.lore ?? 0} onDispatch={dispatchDebugKeepOpen} />
)}
{Object.entries(obj.counters ?? {})
.flatMap(([counterType, count]) => {
const current = count ?? 0;
return current > 0
? [
<CounterRow
key={counterType}
label={formatCounterType(counterType)}
objectId={objectId}
counterType={counterType}
current={current}
onDispatch={dispatchDebugKeepOpen}
Comment on lines +253 to +259

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Keep engine counter identifiers raw.

formatCounterType changes serialized counter identifiers such as "P1P1" before display. Pass the engine value unchanged.

  • client/src/components/chrome/DebugCardContextMenu.tsx#L253-L259: use counterType as the row label.
  • client/src/components/chrome/DebugObjectActions.tsx#L227-L232: do not pass formatCounterType to SelectInput.

Based on learnings, engine-provided enum strings must stay raw.

📍 Affects 2 files
  • client/src/components/chrome/DebugCardContextMenu.tsx#L253-L259 (this comment)
  • client/src/components/chrome/DebugObjectActions.tsx#L227-L232
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/chrome/DebugCardContextMenu.tsx` around lines 253 -
259, Keep engine counter identifiers raw at both affected sites: in
client/src/components/chrome/DebugCardContextMenu.tsx lines 253-259, pass
counterType directly as the CounterRow label instead of formatCounterType; in
client/src/components/chrome/DebugObjectActions.tsx lines 227-232, pass the raw
counterType value to SelectInput instead of formatting it.

Sources: Path instructions, Learnings

/>,
]
: [];
})}
</div>
)}

Expand Down
24 changes: 23 additions & 1 deletion client/src/components/chrome/DebugCreateActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ function CreateCardForm({ onDispatch }: Props) {
const [cardName, setCardName] = useState("");
const [owner, setOwner] = useState<PlayerId>(0);
const [zone, setZone] = useState<Zone>("Hand");
const [count, setCount] = useState(1);
// Gate the ETB pipeline for battlefield spawns. Checked = run replacements +
// ETB triggers + SBAs (engine default); unchecked = raw placement. Only sent
// meaningfully for Battlefield — the engine ignores it for other zones.
Expand Down Expand Up @@ -231,6 +232,9 @@ function CreateCardForm({ onDispatch }: Props) {
<FieldRow label="Zone">
<SelectInput value={zone} onChange={setZone} options={ZONES} />
</FieldRow>
<FieldRow label="Copies">
<NumberInput value={count} onChange={setCount} min={0} />
</FieldRow>
{showAttachPicker && (
<>
{info.canTargetPlayer && info.canTargetObject && (
Expand Down Expand Up @@ -281,6 +285,7 @@ function CreateCardForm({ onDispatch }: Props) {
attach_to: buildAttachTo(),
run_etb: runEtb,
nonlegendary,
count,
},
})
}
Expand Down Expand Up @@ -393,6 +398,7 @@ export function buildCatalogTokenDebugAction({
counterType,
counterCount,
runEtb,
count,
powerOverride,
toughnessOverride,
}: {
Expand All @@ -401,6 +407,7 @@ export function buildCatalogTokenDebugAction({
counterType: CounterType;
counterCount: number;
runEtb: boolean;
count: number;
powerOverride?: number | null;
toughnessOverride?: number | null;
}): CreateTokenDebugAction | null {
Expand All @@ -424,6 +431,7 @@ export function buildCatalogTokenDebugAction({
},
},
run_etb: runEtb,
count,
},
};
}
Expand All @@ -440,6 +448,7 @@ function CatalogTokenForm({ onDispatch }: Props) {
const [counterType, setCounterType] = useState<CounterType>("P1P1");
const [counterCount, setCounterCount] = useState(0);
const [runEtb, setRunEtb] = useState(true);
const [count, setCount] = useState(1);

useEffect(() => {
listTokenPresets()
Expand Down Expand Up @@ -527,6 +536,7 @@ function CatalogTokenForm({ onDispatch }: Props) {
counterType,
counterCount,
runEtb,
count,
powerOverride,
toughnessOverride,
});
Expand All @@ -552,6 +562,9 @@ function CatalogTokenForm({ onDispatch }: Props) {
<FieldRow label="Search">
<TextInput value={search} onChange={setSearch} placeholder="Token, source card, set" />
</FieldRow>
<FieldRow label="Copies">
<NumberInput value={count} onChange={setCount} min={0} />
</FieldRow>
<div className="mb-2 max-h-64 overflow-y-auto rounded border border-gray-800 bg-gray-950/40 p-1">
{orderedGroups.length === 0 && (
<div className="px-2 py-2 text-xs text-gray-500">No presets match.</div>
Expand Down Expand Up @@ -649,6 +662,7 @@ function CustomTokenForm({ onDispatch }: Props) {
const [counterType, setCounterType] = useState<CounterType>("P1P1");
const [counterCount, setCounterCount] = useState(0);
const [runEtb, setRunEtb] = useState(true);
const [count, setCount] = useState(1);

const toggleCoreType = (ct: CoreType) => {
setCoreTypes((prev) =>
Expand Down Expand Up @@ -693,6 +707,7 @@ function CustomTokenForm({ onDispatch }: Props) {
},
},
run_etb: runEtb,
count,
},
});
};
Expand All @@ -713,6 +728,9 @@ function CustomTokenForm({ onDispatch }: Props) {
<FieldRow label="Owner">
<PlayerSelect value={owner} onChange={setOwner} />
</FieldRow>
<FieldRow label="Copies">
<NumberInput value={count} onChange={setCount} min={0} />
</FieldRow>
<FieldRow label="Power">
<NumberInput value={power} onChange={setPower} />
</FieldRow>
Expand Down Expand Up @@ -779,6 +797,7 @@ function CopyPermanentForm({ onDispatch }: Props) {
const [sourceId, setSourceId] = useState<ObjectId | null>(null);
const [owner, setOwner] = useState<PlayerId>(0);
const [nonlegendary, setNonlegendary] = useState(false);
const [count, setCount] = useState(1);

return (
<>
Expand All @@ -794,6 +813,9 @@ function CopyPermanentForm({ onDispatch }: Props) {
<FieldRow label="Owner">
<PlayerSelect value={owner} onChange={setOwner} />
</FieldRow>
<FieldRow label="Copies">
<NumberInput value={count} onChange={setCount} min={0} />
</FieldRow>
<FieldRow label="">
<CheckboxInput
checked={nonlegendary}
Expand All @@ -806,7 +828,7 @@ function CopyPermanentForm({ onDispatch }: Props) {
if (sourceId == null) return;
onDispatch({
type: "CreateTokenCopy",
data: { source_id: sourceId, owner, nonlegendary },
data: { source_id: sourceId, owner, nonlegendary, count },
});
}}
disabled={sourceId == null}
Expand Down
21 changes: 19 additions & 2 deletions client/src/components/chrome/DebugObjectActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
Zone,
} from "../../adapter/types";
import { useGameStore } from "../../stores/gameStore";
import { formatCounterType } from "../../viewmodel/cardProps";
import {
AccordionItem,
CheckboxInput,
Expand Down Expand Up @@ -138,6 +139,7 @@ function CreateTokenCopyForm({ onDispatch }: Props) {
const [sourceId, setSourceId] = useState<ObjectId | null>(null);
const [owner, setOwner] = useState<PlayerId>(0);
const [nonlegendary, setNonlegendary] = useState(false);
const [count, setCount] = useState(1);

return (
<>
Expand All @@ -150,6 +152,9 @@ function CreateTokenCopyForm({ onDispatch }: Props) {
<FieldRow label="Owner">
<PlayerSelect value={owner} onChange={setOwner} />
</FieldRow>
<FieldRow label="Copies">
<NumberInput value={count} onChange={setCount} min={0} />
</FieldRow>
Comment on lines +155 to +157

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Localize the new "Copies" label.

This is frontend-authored user-facing text. Route it through t() and add the locale entries.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/src/components/chrome/DebugObjectActions.tsx` around lines 155 - 157,
Update the “Copies” label in the DebugObjectActions component to use the
existing t() localization function, and add the corresponding locale entries for
this key in the supported translation resources.

Source: Path instructions

<FieldRow label="">
<CheckboxInput
checked={nonlegendary}
Expand All @@ -163,7 +168,7 @@ function CreateTokenCopyForm({ onDispatch }: Props) {
sourceId != null &&
onDispatch({
type: "CreateTokenCopy",
data: { source_id: sourceId, owner, nonlegendary },
data: { source_id: sourceId, owner, nonlegendary, count },
})
}
>
Expand Down Expand Up @@ -207,12 +212,24 @@ function ModifyCountersForm({ onDispatch }: Props) {
const [objectId, setObjectId] = useState<ObjectId | null>(null);
const [counterType, setCounterType] = useState<CounterType>("P1P1");
const [delta, setDelta] = useState(1);
const object = useGameStore((s) =>
objectId == null ? undefined : s.gameState?.objects[objectId],
);
const counterTypes = useMemo<CounterType[]>(
() => Array.from(new Set([...COUNTER_TYPES, ...Object.keys(object?.counters ?? {})])),
[object?.counters],
);

return (
<>
<ObjectSelect value={objectId} onChange={setObjectId} filter={onBattlefield} />
<FieldRow label="Counter">
<SelectInput value={counterType} onChange={setCounterType} options={COUNTER_TYPES} />
<SelectInput
value={counterType}
onChange={setCounterType}
options={counterTypes}
getOptionLabel={formatCounterType}
/>
</FieldRow>
<FieldRow label="Delta">
<NumberInput value={delta} onChange={setDelta} />
Expand Down
Loading
Loading