-
-
Notifications
You must be signed in to change notification settings - Fork 187
fix(parser,engine): absorb all-revealed library placement and bind RevealUntil hit referent (Erratic Mutation) #8929
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
base: main
Are you sure you want to change the base?
Changes from 13 commits
a66ce71
9358282
e3c5fae
538df8e
6e7792c
c2d84cd
9318c8c
5df807e
bad7d56
b06715b
3a4c846
5c956df
e1eef98
99e127d
9dd2763
3b6e26e
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,155 @@ | ||
| import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| import type { GameObject, ViewerInteraction, WaitingFor } from "../../../adapter/types.ts"; | ||
| import { useGameStore } from "../../../stores/gameStore.ts"; | ||
| import { useMultiplayerStore } from "../../../stores/multiplayerStore.ts"; | ||
| import { buildGameObject } from "../../../test/factories/gameObjectFactory.ts"; | ||
| import { buildGameState, buildPlayer } from "../../../test/factories/gameStateFactory.ts"; | ||
| import { CardChoiceModal } from "../CardChoiceModal.tsx"; | ||
|
|
||
| const dispatchMock = vi.fn(); | ||
|
|
||
| vi.mock("../../../hooks/useGameDispatch.ts", () => ({ | ||
| useGameDispatch: () => dispatchMock, | ||
| })); | ||
|
|
||
| function makeObject(id: number, name: string): GameObject { | ||
| return buildGameObject({ | ||
| id, | ||
| card_id: id, | ||
| zone: "Library", | ||
| name, | ||
| card_types: { supertypes: [], core_types: ["Instant"], subtypes: [] }, | ||
| mana_cost: { type: "Cost", shards: [], generic: 1 }, | ||
| timestamp: id, | ||
| }); | ||
| } | ||
|
|
||
| function setWaitingFor( | ||
| waitingFor: WaitingFor, | ||
| objects: Record<string, GameObject>, | ||
| viewerInteraction?: ViewerInteraction, | ||
| ) { | ||
| const state = buildGameState({ | ||
| players: [buildPlayer({ id: 0, library: [10, 11] }), buildPlayer({ id: 1 })], | ||
| objects, | ||
| waiting_for: waitingFor, | ||
| next_object_id: 100, | ||
| }); | ||
| useGameStore.setState({ | ||
| gameMode: "online", | ||
| gameState: state, | ||
| waitingFor, | ||
| viewerInteraction, | ||
| }); | ||
| } | ||
|
|
||
| function selectInteraction(id: string): ViewerInteraction { | ||
| return { | ||
| opportunities: [ | ||
| { | ||
| interactionId: id, | ||
| response: { type: "schema", data: { spec: { type: "select" } } }, | ||
| }, | ||
| ], | ||
| } as unknown as ViewerInteraction; | ||
| } | ||
|
|
||
| describe("RevealUntilBottomOrderModal", () => { | ||
| beforeEach(() => { | ||
| dispatchMock.mockClear(); | ||
| useMultiplayerStore.setState({ activePlayerId: 0 }); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| cleanup(); | ||
| }); | ||
|
|
||
| it("renders cards and dispatches SelectCards with ordered cards on confirm", () => { | ||
| setWaitingFor( | ||
| { | ||
| type: "RevealUntilBottomOrder", | ||
| data: { | ||
| player: 0, | ||
| source_id: 1, | ||
| cards: [10, 11], | ||
| }, | ||
| }, | ||
| { | ||
| 10: makeObject(10, "Lightning Bolt"), | ||
| 11: makeObject(11, "Counterspell"), | ||
| }, | ||
| ); | ||
|
|
||
| render(<CardChoiceModal />); | ||
|
|
||
| expect(screen.getByText(/Order the rest on the bottom/i)).toBeInTheDocument(); | ||
| expect(screen.getByText(/Put 2 revealed cards on the bottom of your library/i)).toBeInTheDocument(); | ||
| expect(screen.getByLabelText(/Lightning Bolt/i)).toBeInTheDocument(); | ||
| expect(screen.getByLabelText(/Counterspell/i)).toBeInTheDocument(); | ||
|
|
||
| const confirmButton = screen.getByRole("button", { name: /Done|Confirm/i }); | ||
| fireEvent.click(confirmButton); | ||
|
|
||
| expect(dispatchMock).toHaveBeenCalledWith({ | ||
| type: "SelectCards", | ||
| data: { cards: [10, 11] }, | ||
| }); | ||
| }); | ||
|
|
||
| it("allows reordering cards with move buttons and dispatches changed permutation", () => { | ||
| setWaitingFor( | ||
| { | ||
| type: "RevealUntilBottomOrder", | ||
| data: { | ||
| player: 0, | ||
| source_id: 1, | ||
| cards: [10, 11], | ||
| }, | ||
| }, | ||
| { | ||
| 10: makeObject(10, "Lightning Bolt"), | ||
| 11: makeObject(11, "Counterspell"), | ||
| }, | ||
| ); | ||
|
|
||
| render(<CardChoiceModal />); | ||
|
|
||
| const moveRightButtons = screen.getAllByRole("button", { name: /Move right/i }); | ||
| expect(moveRightButtons[0]).not.toBeDisabled(); | ||
| fireEvent.click(moveRightButtons[0]); | ||
|
|
||
| const confirmButton = screen.getByRole("button", { name: /Done|Confirm/i }); | ||
| fireEvent.click(confirmButton); | ||
|
|
||
| expect(dispatchMock).toHaveBeenCalledWith({ | ||
| type: "SelectCards", | ||
| data: { cards: [11, 10] }, | ||
| }); | ||
| }); | ||
|
|
||
| it("resets the order for a new interaction with the same cards", () => { | ||
| const waitingFor: WaitingFor = { | ||
| type: "RevealUntilBottomOrder", | ||
| data: { player: 0, source_id: 1, cards: [10, 11] }, | ||
| }; | ||
| const objects = { | ||
| 10: makeObject(10, "Lightning Bolt"), | ||
| 11: makeObject(11, "Counterspell"), | ||
| }; | ||
| setWaitingFor(waitingFor, objects, selectInteraction("session.1.1")); | ||
| render(<CardChoiceModal />); | ||
|
|
||
| fireEvent.click(screen.getAllByRole("button", { name: /Move right/i })[0]); | ||
| act(() => { | ||
| setWaitingFor(waitingFor, objects, selectInteraction("session.1.2")); | ||
| }); | ||
|
|
||
| fireEvent.click(screen.getByRole("button", { name: /Done|Confirm/i })); | ||
| expect(dispatchMock).toHaveBeenLastCalledWith({ | ||
| type: "SelectCards", | ||
| data: { cards: [10, 11] }, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,10 @@ type DigChoice = Extract<WaitingFor, { type: "DigChoice" }>; | |
| type SurveilChoice = Extract<WaitingFor, { type: "SurveilChoice" }>; | ||
| type RevealChoice = Extract<WaitingFor, { type: "RevealChoice" }>; | ||
| type RippleBottomOrder = Extract<WaitingFor, { type: "RippleBottomOrder" }>; | ||
| type RevealUntilBottomOrder = Extract< | ||
| WaitingFor, | ||
| { type: "RevealUntilBottomOrder" } | ||
| >; | ||
|
|
||
| export function ReorderableTopChoice({ | ||
| cards, | ||
|
|
@@ -281,6 +285,125 @@ export function RippleBottomOrderModal({ | |
| ); | ||
| } | ||
|
|
||
| /** | ||
| * CR 701.20a + CR 608.2d: In any order bottom placement for RevealUntil | ||
| * ("put the rest of the revealed cards on the bottom of your library in any order"). | ||
| * The player drag-reorders the revealed pile, then submits them to the | ||
| * bottom in that sequence (`SelectCards` carrying the full permutation). | ||
| */ | ||
| export function RevealUntilBottomOrderModal({ | ||
| data, | ||
| }: { | ||
| data: RevealUntilBottomOrder["data"]; | ||
| }) { | ||
| const { t } = useTranslation("game"); | ||
| const dispatch = useGameDispatch(); | ||
| const objects = useGameStore((s) => s.gameState?.objects); | ||
| const hoverProps = useInspectHoverProps(); | ||
| const scrollRef = useHorizontalScroll<HTMLDivElement>({ drag: false }); | ||
| const [order, setOrder] = useState<ObjectId[]>(data.cards); | ||
|
|
||
| const move = useCallback( | ||
| (from: number, to: number) => { | ||
| if (to < 0 || to >= order.length) return; | ||
| setOrder((prev) => { | ||
| const next = [...prev]; | ||
| const [item] = next.splice(from, 1); | ||
| next.splice(to, 0, item); | ||
| return next; | ||
| }); | ||
| }, | ||
| [order.length], | ||
| ); | ||
|
|
||
| if (!objects) return null; | ||
|
|
||
| return ( | ||
| <ChoiceOverlay | ||
| title={t("cardChoice.revealUntilBottom.title")} | ||
| subtitle={t("cardChoice.revealUntilBottom.subtitle", { count: data.cards.length })} | ||
| maxWidthClassName="max-w-[38rem] sm:max-w-[48rem] lg:max-w-[58rem]" | ||
| footer={ | ||
| <ConfirmButton | ||
| onClick={() => | ||
| dispatch({ type: "SelectCards", data: { cards: order } }) | ||
| } | ||
| /> | ||
| } | ||
| > | ||
| <div ref={scrollRef} className="flex min-h-0 flex-1 overflow-x-auto"> | ||
| <Reorder.Group | ||
| as="div" | ||
| axis="x" | ||
| values={order} | ||
| onReorder={setOrder} | ||
| layoutScroll | ||
| className="mx-auto flex w-max items-center gap-2 px-1 py-2 lg:gap-3" | ||
| > | ||
| {order.map((id, index) => { | ||
| const obj = objects[id]; | ||
| if (!obj) return null; | ||
| return ( | ||
| <Reorder.Item | ||
| key={id} | ||
| as="div" | ||
| value={id} | ||
| className="relative flex shrink-0 cursor-grab flex-col items-center gap-2 active:cursor-grabbing" | ||
| whileDrag={{ scale: 1.05, zIndex: 20 }} | ||
|
Comment on lines
+347
to
+352
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 | 🏗️ Heavy lift Provide keyboard controls for card ordering.
🤖 Prompt for AI AgentsSource: Path instructions |
||
| > | ||
| <div | ||
| className="relative rounded-lg ring-2 ring-amber-400/70 transition hover:shadow-[0_0_16px_rgba(245,180,80,0.3)]" | ||
| {...hoverProps(id)} | ||
| > | ||
| <CardImage | ||
| {...objectImageProps(obj)} | ||
| size="normal" | ||
| className={CHOICE_CARD_IMAGE_CLASS} | ||
| /> | ||
| <div className="pointer-events-none absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-amber-500/90 text-xs font-bold text-white"> | ||
| {index + 1} | ||
| </div> | ||
| </div> | ||
| {order.length > 1 && ( | ||
| <div className="flex gap-1"> | ||
| <button | ||
| type="button" | ||
| aria-label={t("cardChoice.revealUntilBottom.moveLeft")} | ||
| disabled={index === 0} | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| move(index, index - 1); | ||
| }} | ||
| className="rounded bg-slate-700/80 px-2 py-0.5 text-xs text-white transition hover:bg-slate-600 disabled:opacity-30" | ||
| > | ||
| ← | ||
| </button> | ||
| <button | ||
| type="button" | ||
| aria-label={t("cardChoice.revealUntilBottom.moveRight")} | ||
| disabled={index === order.length - 1} | ||
| onClick={(e) => { | ||
| e.stopPropagation(); | ||
| move(index, index + 1); | ||
| }} | ||
| className="rounded bg-slate-700/80 px-2 py-0.5 text-xs text-white transition hover:bg-slate-600 disabled:opacity-30" | ||
| > | ||
| → | ||
| </button> | ||
| </div> | ||
| )} | ||
| </Reorder.Item> | ||
| ); | ||
| })} | ||
| </Reorder.Group> | ||
| </div> | ||
| <p className="mt-1 shrink-0 text-center text-xs text-slate-400"> | ||
| {t("cardChoice.revealUntilBottom.hint")} | ||
| </p> | ||
| </ChoiceOverlay> | ||
| ); | ||
| } | ||
|
|
||
| export function CoinFlipKeepModal({ data }: { data: CoinFlipKeepChoice["data"] }) { | ||
| const { t } = useTranslation("game"); | ||
| const dispatch = useGameDispatch(); | ||
|
|
||
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.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Test a changed card order before confirmation.
This test confirms only the initial
[10, 11]order. A modal that ignores drag reordering and always dispatches its input order will pass.Reorder the cards through the production interaction, then assert that
SelectCardscontains the changed order. This verifies the controller choice required for “in any order” library placement.As per path instructions, a test must exercise the failure path that the fix prevents.
🤖 Prompt for AI Agents
Source: Path instructions