-
-
Notifications
You must be signed in to change notification settings - Fork 153
ship/sandbox deck sync #7123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ship/sandbox deck sync #7123
Changes from all commits
5f0d84a
1dc8f25
708e8be
2f880a7
d54542f
d954d96
4d67f5d
04114ad
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
|
|
@@ -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 ?? []; | ||
|
|
||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Keep engine counter identifiers raw.
Based on learnings, engine-provided enum strings must stay raw. 📍 Affects 2 files
🤖 Prompt for AI AgentsSources: Path instructions, Learnings |
||
| />, | ||
| ] | ||
| : []; | ||
| })} | ||
| </div> | ||
| )} | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,6 +10,7 @@ import type { | |
| Zone, | ||
| } from "../../adapter/types"; | ||
| import { useGameStore } from "../../stores/gameStore"; | ||
| import { formatCounterType } from "../../viewmodel/cardProps"; | ||
| import { | ||
| AccordionItem, | ||
| CheckboxInput, | ||
|
|
@@ -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 ( | ||
| <> | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Localize the new This is frontend-authored user-facing text. Route it through 🤖 Prompt for AI AgentsSource: Path instructions |
||
| <FieldRow label=""> | ||
| <CheckboxInput | ||
| checked={nonlegendary} | ||
|
|
@@ -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 }, | ||
| }) | ||
| } | ||
| > | ||
|
|
@@ -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} /> | ||
|
|
||
There was a problem hiding this comment.
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:
Repository: phase-rs/phase
Length of output: 24702
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50371
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 44114
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 299
🏁 Script executed:
Repository: phase-rs/phase
Length of output: 50370
Mirror
PlayerActionKindwith a closed string-literal union.GameEvent::PlayerPerformedActionis a RustPlayerActionKind; its variants are emitted by the engine (Scry,Surveil,Investigate,CollectEvidence,SearchedLibrary,ShuffledLibrary,Proliferate, etc.). Inclient/src/adapter/types.ts, widening it tostringmakes 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