From 41dfa6f84231f22076c59dfdb66cda1218af0376 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 28 Jul 2026 22:08:41 -0700 Subject: [PATCH 01/10] fix(client): authorize attachment interaction fan --- client/src/adapter/engine-worker-client.ts | 8 + client/src/adapter/engine-worker.ts | 16 ++ .../adapter/generated/interaction/index.ts | 8 +- client/src/adapter/p2p-adapter.ts | 87 ++++++++ client/src/adapter/server-draft-adapter.ts | 25 +++ client/src/adapter/types.ts | 8 +- client/src/adapter/wasm-adapter.ts | 22 ++ client/src/adapter/ws-adapter.ts | 22 ++ client/src/components/board/AttachmentFan.tsx | 182 ++++++++-------- client/src/components/board/PermanentCard.tsx | 35 +--- client/src/game/dispatch.ts | 24 +++ client/src/network/protocol.ts | 6 +- .../GamePage.projectedManaChoices.test.ts | 1 + client/src/wasm/engine_wasm.d.ts | 2 + crates/engine-wasm/src/lib.rs | 28 ++- crates/engine/src/bin/interaction_bindings.rs | 20 +- crates/engine/src/game/interaction.rs | 197 ++++++++++++++++-- crates/engine/src/types/interaction.rs | 28 +++ .../tests/integration/interaction_contract.rs | 64 +++++- 19 files changed, 641 insertions(+), 142 deletions(-) diff --git a/client/src/adapter/engine-worker-client.ts b/client/src/adapter/engine-worker-client.ts index 24d73e09d8..c335ec5c69 100644 --- a/client/src/adapter/engine-worker-client.ts +++ b/client/src/adapter/engine-worker-client.ts @@ -16,6 +16,7 @@ import type { ViewerSnapshot, } from "./types"; import { AdapterError, AdapterErrorCode } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { debugLog } from "../game/debugLog"; import { notifyEngineSlow } from "../game/engineRecovery"; @@ -238,6 +239,13 @@ export class EngineWorkerClient { ); } + async submitInteraction(actor: number, submission: InteractionSubmission): Promise { + return this.request( + { type: "submitInteraction", actor, submission }, + ENGINE_REQUEST_TIMEOUT_MS, + ); + } + async previewManaPayment(actor: number, action: GameAction): Promise { return this.request( { type: "previewManaPayment", actor, action }, diff --git a/client/src/adapter/engine-worker.ts b/client/src/adapter/engine-worker.ts index 0d527c8185..9646fcecb0 100644 --- a/client/src/adapter/engine-worker.ts +++ b/client/src/adapter/engine-worker.ts @@ -9,6 +9,7 @@ import init, { take_last_panic_message, initialize_game, submit_action, + submit_interaction_js, get_game_state, get_filtered_game_state, get_ai_action, @@ -43,6 +44,7 @@ import init, { } from "@wasm/engine"; import type { GameAction } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import type { BracketDeckRequest } from "../types/bracketEstimate"; // ── Message Protocol ───────────────────────────────────────────────────── @@ -61,6 +63,7 @@ type EngineRequest = firstPlayer?: number; } | { type: "submitAction"; id: number; actor: number; action: GameAction } + | { type: "submitInteraction"; id: number; actor: number; submission: InteractionSubmission } | { type: "previewManaPayment"; id: number; actor: number; action: GameAction } | { type: "getState"; id: number } | { type: "getFilteredState"; id: number; viewerId: number } @@ -281,6 +284,19 @@ self.onmessage = async (e: MessageEvent) => { break; } + case "submitInteraction": { + const actionResult = submit_interaction_js(msg.actor, msg.submission); + if (typeof actionResult === "string") { + error(msg.id, actionResult); + break; + } + result(msg.id, { + events: actionResult.events ?? [], + log_entries: actionResult.log_entries ?? [], + }); + break; + } + case "previewManaPayment": { const sources = preview_mana_payment_js(msg.actor, msg.action); if (typeof sources === "string") { diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index d836708684..55332187ef 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -11,6 +11,8 @@ export type InteractionActionId = string & { readonly __brand: "InteractionActio export type PreviewRequestId = string & { readonly __brand: "PreviewRequestId" }; +export type InteractionObjectReference = string & { readonly __brand: "InteractionObjectReference" }; + export type InteractionSlotKind = "single" | "mulligan" | "openingBottom"; export type ActiveInteractionSlot = { semanticOwner: number, slotKind: InteractionSlotKind, interactionId: InteractionId, }; @@ -99,9 +101,13 @@ export type InteractionProgress = { selected: number, minimum: number, maximum: export type InteractionOpportunity = { interactionId: InteractionId, response: InteractionOpportunityResponse, surfaces: Array, progress: InteractionProgress, }; +export type InteractionAttachmentFanChild = { object: InteractionObjectReference, choiceIds: Array, }; + +export type InteractionAttachmentFan = { interactionId: InteractionId, host: InteractionObjectReference, children: Array, }; + export type InteractionAvailability = { "type": "progressAvailable", "data": { witness: InteractionSubmission, } } | { "type": "inputRequired" } | { "type": "escapeOnly", "data": { reason: InteractionReasonCode, } } | { "type": "waiting" } | { "type": "terminal", "data": { outcome: InteractionOutcomeCode, } } | { "type": "unsupported", "data": { reason: InteractionReasonCode, } } | { "type": "stuck", "data": { reason: InteractionReasonCode, } }; -export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, availability: InteractionAvailability, }; +export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: Array, availability: InteractionAvailability, }; export type AmountAssignment = { choiceId: InteractionChoiceId, amount: number, }; diff --git a/client/src/adapter/p2p-adapter.ts b/client/src/adapter/p2p-adapter.ts index 6817a5fc4a..c87d8cfcda 100644 --- a/client/src/adapter/p2p-adapter.ts +++ b/client/src/adapter/p2p-adapter.ts @@ -17,6 +17,7 @@ import type { SubmitResult, WaitingFor, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { AdapterError, AdapterErrorCode, EMPTY_LEGAL_ACTIONS, actionRejectionError, nextSnapshotSeq } from "./types"; @@ -260,6 +261,13 @@ class NativeP2PBridge { return this.clientFor(playerId).submitAction(action, playerId); } + async submitInteraction( + submission: InteractionSubmission, + playerId: PlayerId, + ): Promise { + return this.clientFor(playerId).submitInteraction(submission, playerId); + } + async previewManaPayment(action: GameAction, playerId: PlayerId): Promise { return this.clientFor(playerId).previewManaPayment(action, playerId); } @@ -1534,6 +1542,26 @@ export class P2PHostAdapter implements EngineAdapter { return result; } + async submitInteraction( + submission: InteractionSubmission, + actor: PlayerId, + ): Promise { + if (this.gameRunState !== "running") { + throw new AdapterError( + "P2P_PAUSED", + `Cannot submit interaction while game state is ${this.gameRunState}`, + true, + ); + } + const result = this.nativeBridge + ? await this.nativeBridge.submitInteraction(submission, actor) + : await this.wasm.submitInteraction(submission, actor); + await this.broadcastStateUpdate(result.events, result.log_entries); + await this.runAiLoop(); + this.persistAuthoritativeState(); + return result; + } + async previewManaPayment(action: GameAction, actor: PlayerId): Promise { if (this.gameRunState !== "running") { throw new AdapterError( @@ -1818,6 +1846,44 @@ export class P2PHostAdapter implements EngineAdapter { } break; } + case "interaction": { + const session = this.guestSessions.get(pid); + if (!session || msg.senderPlayerId !== pid) { + if (session) session.send({ type: "action_rejected", reason: "senderPlayerId mismatch" }); + return; + } + if (this.eliminatedSeats.has(pid) || this.gameRunState !== "running") { + session.send({ + type: "action_rejected", + reason: this.eliminatedSeats.has(pid) + ? "Player has conceded and can no longer act" + : `Game ${this.gameRunState}`, + }); + return; + } + try { + const result = this.nativeBridge + ? await this.nativeBridge.submitInteraction(msg.submission, pid) + : await this.wasm.submitInteraction(msg.submission, pid); + await this.broadcastStateUpdate(result.events, result.log_entries); + await this.runAiLoop(); + this.persistAuthoritativeState(); + if (!this.nativeBridge) { + this.emit({ + type: "stateChanged", + snapshot: await this.wasm.getSnapshot(), + events: result.events, + logEntries: result.log_entries, + }); + } + } catch (err) { + session.send({ + type: "action_rejected", + reason: err instanceof Error ? err.message : String(err), + }); + } + break; + } case "preview_mana_payment": { const session = this.guestSessions.get(pid); if (!session) return; @@ -2316,6 +2382,27 @@ export class P2PGuestAdapter implements EngineAdapter { }); } + async submitInteraction( + submission: InteractionSubmission, + _actor: PlayerId, + ): Promise { + if (!this.session) { + throw new AdapterError("P2P_ERROR", "Not connected to host", true); + } + if (this.assignedPlayerId === null) { + throw new AdapterError("P2P_ERROR", "Not yet assigned a player ID", true); + } + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + this.session!.send({ + type: "interaction", + senderPlayerId: this.assignedPlayerId!, + submission, + }); + }); + } + async previewManaPayment(action: GameAction, _actor: PlayerId): Promise { if (!this.session) { throw new AdapterError("P2P_ERROR", "Not connected to host", true); diff --git a/client/src/adapter/server-draft-adapter.ts b/client/src/adapter/server-draft-adapter.ts index 70f7bacb4f..f836eb57a5 100644 --- a/client/src/adapter/server-draft-adapter.ts +++ b/client/src/adapter/server-draft-adapter.ts @@ -12,6 +12,7 @@ import type { PlayerId, SubmitResult, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import { actionRejectionError, AdapterError, AdapterErrorCode, EMPTY_LEGAL_ACTIONS, nextSnapshotSeq } from "./types"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { @@ -199,6 +200,30 @@ export class ServerDraftAdapter implements EngineAdapter { }); } + async submitInteraction( + submission: InteractionSubmission, + _actor: PlayerId, + ): Promise { + if (this.phase !== "match") { + throw new AdapterError("PHASE_ERROR", "Not in a match phase", false); + } + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new AdapterError("WS_ERROR", "WebSocket not connected", false); + } + + this.emit({ type: "actionPendingChanged", pending: true }); + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + if (!this.send({ type: "Interaction", data: { submission } })) { + this.pendingResolve = null; + this.pendingReject = null; + this.emit({ type: "actionPendingChanged", pending: false }); + reject(new AdapterError("WS_CLOSED", "Failed to send interaction", true)); + } + }); + } + async previewManaPayment(action: GameAction, _actor: PlayerId): Promise { if (this.phase !== "match") { throw new AdapterError("PHASE_ERROR", "Not in a match phase", false); diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index a25064c8a1..efea4a6ae9 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -1,5 +1,9 @@ import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; -import type { InteractionActionId, ViewerInteraction } from "./generated/interaction"; +import type { + InteractionActionId, + InteractionSubmission, + ViewerInteraction, +} from "./generated/interaction"; // ── Identifiers ────────────────────────────────────────────────────────── @@ -3518,6 +3522,8 @@ export interface EngineAdapter { * action payload or the UI state. */ submitAction(action: GameAction, actor: PlayerId): Promise; + /** Submit an opaque response from the engine's current interaction projection. */ + submitInteraction?(submission: InteractionSubmission, actor: PlayerId): Promise; /** * Read-only preview of the exact automatic `CastSpell` action currently * offered by the engine. Unsupported transports omit this capability. diff --git a/client/src/adapter/wasm-adapter.ts b/client/src/adapter/wasm-adapter.ts index d673d85918..b94c13b4cb 100644 --- a/client/src/adapter/wasm-adapter.ts +++ b/client/src/adapter/wasm-adapter.ts @@ -14,6 +14,7 @@ import type { ViewerSnapshot, WaitingFor, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import { AdapterError, AdapterErrorCode, isStaleRejectionMessage, isStateLostMessage, nextSnapshotSeq } from "./types"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { isBracketEstimate } from "../types/bracketEstimate"; @@ -278,6 +279,19 @@ export class WasmAdapter implements EngineAdapter { } } + async submitInteraction( + submission: InteractionSubmission, + actor: PlayerId, + ): Promise { + this.assertInitialized(); + try { + if (this.engine) return await this.engine.submitInteraction(actor, submission); + return await this.fallback!.submitInteraction(submission, actor); + } catch (err) { + throw await classifyEngineErrorAsync(err, this.takePanic); + } + } + async previewManaPayment(action: GameAction, actor: PlayerId): Promise { this.assertInitialized(); try { @@ -838,6 +852,7 @@ export class WasmAdapter implements EngineAdapter { interface MainThreadFallback { ensureCardDatabase(): Promise; submitAction(action: GameAction, actor: PlayerId): Promise; + submitInteraction(submission: InteractionSubmission, actor: PlayerId): Promise; previewManaPayment(action: GameAction, actor: PlayerId): Promise; getState(): Promise; getFilteredState(viewerId: number): Promise; @@ -894,6 +909,13 @@ async function createMainThreadFallback(): Promise { return { events: r.events ?? [], log_entries: r.log_entries ?? [] }; }), + submitInteraction: (submission: InteractionSubmission, actor: PlayerId) => + enqueue(() => { + const r = wasm.submit_interaction_js(actor, submission); + if (typeof r === "string") throw new Error(r); + return { events: r.events ?? [], log_entries: r.log_entries ?? [] }; + }), + previewManaPayment: (action: GameAction, actor: PlayerId) => enqueue(() => { const sources = wasm.preview_mana_payment_js(actor, action); diff --git a/client/src/adapter/ws-adapter.ts b/client/src/adapter/ws-adapter.ts index 97a304be35..b8bfc30db9 100644 --- a/client/src/adapter/ws-adapter.ts +++ b/client/src/adapter/ws-adapter.ts @@ -14,6 +14,7 @@ import type { SubmitResult, FormatConfig, } from "./types"; +import type { InteractionSubmission } from "./generated/interaction"; import { AdapterError, AdapterErrorCode, EMPTY_LEGAL_ACTIONS, actionRejectionError, nextSnapshotSeq } from "./types"; import type { BracketDeckRequest, BracketEstimate } from "../types/bracketEstimate"; import { @@ -637,6 +638,27 @@ export class WebSocketAdapter implements EngineAdapter { }); } + async submitInteraction( + submission: InteractionSubmission, + _actor: PlayerId, + ): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new AdapterError("WS_ERROR", "WebSocket not connected", false); + } + + this.emit({ type: "actionPendingChanged", pending: true }); + return new Promise((resolve, reject) => { + this.pendingResolve = resolve; + this.pendingReject = reject; + if (!this.send({ type: "Interaction", data: { submission } })) { + this.pendingResolve = null; + this.pendingReject = null; + this.emit({ type: "actionPendingChanged", pending: false }); + reject(new AdapterError("WS_CLOSED", "Failed to send interaction", true)); + } + }); + } + async previewManaPayment(action: GameAction, _actor: PlayerId): Promise { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new AdapterError("WS_ERROR", "WebSocket not connected", false); diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index ed328461f8..0457dc1351 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -1,11 +1,15 @@ -import { type CSSProperties, useCallback, useEffect, useMemo } from "react"; +import { type CSSProperties, useCallback, useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; -import type { GameAction, ObjectId } from "../../adapter/types.ts"; -import { dispatchAction } from "../../game/dispatch.ts"; -import { usePlayerId } from "../../hooks/usePlayerId.ts"; +import type { ObjectId } from "../../adapter/types.ts"; +import type { + InteractionChoiceId, + InteractionOpportunity, + InteractionResponse, +} from "../../adapter/generated/interaction"; +import { dispatchInteraction } from "../../game/dispatch.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; @@ -56,10 +60,8 @@ function fanCardSizingStyle(cardCount: number): CSSProperties { * the same precedence PermanentCard uses on the battlefield. */ interface CardChoice { - isTarget: boolean; - boardEligible: boolean; + choiceId: InteractionChoiceId | null; isSelected: boolean; - activationActions: GameAction[]; } /** @@ -82,43 +84,52 @@ interface CardChoice { */ export function AttachmentFan() { const { t } = useTranslation("game"); - const playerId = usePlayerId(); const hostId = useUiStore((s) => s.attachmentFanHostId); const setAttachmentFanHost = useUiStore((s) => s.setAttachmentFanHost); const dismissPreview = useUiStore((s) => s.dismissPreview); - const setPendingAbilityChoice = useUiStore((s) => s.setPendingAbilityChoice); - const toggleSelectedCard = useUiStore((s) => s.toggleSelectedCard); - const selectedCardIds = useUiStore((s) => s.selectedCardIds); const objects = useGameStore((s) => s.gameState?.objects); - const waitingFor = useGameStore((s) => s.waitingFor); - const legalActionsByObject = useGameStore((s) => s.legalActionsByObject); + const viewerInteraction = useGameStore((s) => s.viewerInteraction); + const [selectedChoiceIds, setSelectedChoiceIds] = useState([]); const host = hostId != null ? objects?.[hostId] : undefined; + const interactionFan = useMemo( + () => + hostId == null + ? null + : (viewerInteraction?.attachmentFans.find( + (fan) => Number(fan.host) === hostId, + ) ?? null), + [hostId, viewerInteraction], + ); - const cardIds = host ? [host.id, ...host.attachments] : []; - - const boardChoice = useMemo(() => { - const choice = getBoardChoiceView(waitingFor, objects); - return choice && choice.player === playerId ? choice : null; - }, [waitingFor, objects, playerId]); + // During an interaction, the engine projection is the sole authority for + // which direct attachments belong in the fan. The fallback preserves the + // existing read-only badge outside an interaction, where no choice is being + // exposed and therefore no interaction capability exists to consume. + const cardIds = host + ? [ + host.id, + ...(interactionFan + ? interactionFan.children.map((child) => Number(child.object)) + : host.attachments), + ] + : []; - // Engine's live target legal-set for the current prompt, as a plain id set. - const targetIds = useMemo(() => { - const set = new Set(); - if ( - (waitingFor?.type === "TargetSelection" || waitingFor?.type === "TriggerTargetSelection") - && waitingFor.data.player === playerId - ) { - for (const target of waitingFor.data.selection?.current_legal_targets ?? []) { - if ("Object" in target) set.add(target.Object); - } - } - if (waitingFor?.type === "EquipTarget" && waitingFor.data.player === playerId) { - for (const id of waitingFor.data.valid_targets) set.add(id); - } - return set; - }, [waitingFor, playerId]); + const opportunity = useMemo( + () => + interactionFan + ? (viewerInteraction?.opportunities.find( + (candidate) => candidate.interactionId === interactionFan.interactionId, + ) ?? null) + : null, + [interactionFan, viewerInteraction], + ); + const requiresConfirmation = + opportunity?.response.type === "schema" && + (opportunity.response.data.spec.type === "select" || + opportunity.response.data.spec.type === "sequence") && + opportunity.response.data.spec.data.confirm === "explicit"; const close = useCallback(() => { setAttachmentFanHost(null); @@ -138,58 +149,38 @@ export function AttachmentFan() { }, [hostId, close]); const choiceFor = useCallback( - (id: ObjectId): CardChoice => ({ - isTarget: targetIds.has(id), - boardEligible: boardChoice?.objectIds.includes(id) ?? false, - isSelected: selectedCardIds.includes(id), - activationActions: legalActionsByObject ? collectObjectActions(legalActionsByObject, id) : [], - }), - [targetIds, boardChoice, selectedCardIds, legalActionsByObject], + (id: ObjectId): CardChoice => { + const choiceIds = interactionFan?.children.find((child) => Number(child.object) === id)?.choiceIds ?? []; + const choiceId = choiceIds[0] ?? null; + return { + choiceId, + isSelected: choiceId !== null && selectedChoiceIds.includes(choiceId), + }; + }, + [interactionFan, selectedChoiceIds], ); const handlePick = useCallback( - (id: ObjectId, choice: CardChoice) => { - if (choice.isTarget) { - dispatchAction({ type: "ChooseTarget", data: { target: { Object: id } } }); - close(); + (_id: ObjectId, choice: CardChoice) => { + if (!choice.choiceId || !opportunity || !viewerInteraction?.canSubmit) return; + if (requiresConfirmation) { + setSelectedChoiceIds((selected) => + selected.includes(choice.choiceId!) + ? selected.filter((id) => id !== choice.choiceId) + : [...selected, choice.choiceId!], + ); return; } - if (choice.boardEligible && boardChoice) { - if (isBoardChoiceImmediate(boardChoice)) { - dispatchAction(buildBoardChoiceAction(boardChoice, [id])); - close(); - return; - } - // Multi-select: toggle within the engine's max, then Confirm finishes. - const max = boardChoiceMaxSelection(boardChoice); - const selectedForChoice = selectedCardIds.filter((s) => boardChoice.objectIds.includes(s)); - if (choice.isSelected || max == null || selectedForChoice.length < max) { - toggleSelectedCard(id); - } - return; - } - if (choice.activationActions.length > 0) { - if (choice.activationActions.length === 1) { - dispatchAction(choice.activationActions[0]); - } else { - setPendingAbilityChoice({ objectId: id, actions: choice.activationActions }); - } - close(); - } + const response = responseForChoices(opportunity, [choice.choiceId]); + if (!response) return; + void dispatchInteraction({ interactionId: opportunity.interactionId, response }).then(close).catch(() => {}); }, - [boardChoice, selectedCardIds, close, toggleSelectedCard, setPendingAbilityChoice], + [close, opportunity, requiresConfirmation, viewerInteraction?.canSubmit], ); - const confirmSelection = useMemo(() => { - if (!boardChoice || isBoardChoiceImmediate(boardChoice)) return null; - const selectedForChoice = selectedCardIds.filter((s) => boardChoice.objectIds.includes(s)); - if (selectedForChoice.length === 0) return null; - return { - enabled: canConfirmBoardChoice(boardChoice, selectedForChoice, objects), - selected: selectedForChoice, - choice: boardChoice, - }; - }, [boardChoice, selectedCardIds, objects]); + const confirmSelection = requiresConfirmation && opportunity + ? responseForChoices(opportunity, selectedChoiceIds) + : null; if (hostId == null || !host || cardIds.length === 0) return null; @@ -230,18 +221,20 @@ export function AttachmentFan() { ))} - {confirmSelection && ( + {confirmSelection && opportunity && ( )} , @@ -249,6 +242,23 @@ export function AttachmentFan() { ); } +function responseForChoices( + opportunity: InteractionOpportunity, + choiceIds: InteractionChoiceId[], +): InteractionResponse | null { + if (opportunity.response.type === "exactChoices") { + return choiceIds.length === 1 ? { type: "choose", data: { choiceId: choiceIds[0] } } : null; + } + switch (opportunity.response.data.spec.type) { + case "select": + return { type: "select", data: { choiceIds } }; + case "sequence": + return { type: "sequence", data: { choiceIds } }; + default: + return null; + } +} + function FanCard({ objectId, choice, @@ -272,7 +282,7 @@ function FanCard({ const lookup = cardImageLookup(obj); const isToken = obj.display_source === "Token"; - const selectable = choice.isTarget || choice.boardEligible || choice.activationActions.length > 0; + const selectable = choice.choiceId !== null; // The whole fan speaks one "pick me" color — cyan — so a spread of a host and // its attachments reads as a single chooser regardless of whether the engine diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index f1009f32a4..0ce238bb5e 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -464,6 +464,12 @@ export const PermanentCard = memo(function PermanentCard({ const controllerIdentity = useGameStore( (s) => obj && s.gameState?.players?.find((p) => p.id === obj.controller)?.commander_color_identity, ); + const viewerInteraction = useGameStore((s) => s.viewerInteraction); + const interactionAttachmentFan = useMemo( + () => + viewerInteraction?.attachmentFans.find((fan) => Number(fan.host) === objectId) ?? null, + [objectId, viewerInteraction], + ); const showAttachmentFan = useCallback(() => { dismissPreview(); @@ -490,31 +496,10 @@ export const PermanentCard = memo(function PermanentCard({ const ptDisplay = computePTDisplay(obj); const isSelected = selectedObjectId === objectId; - // CR 301.5 / CR 303.4: An attached Equipment/Aura is an independent permanent - // that can be a valid target, an activation source (re-equip), or a board - // choice in its own right. Collapsed behind its host it is unreachable — - // clicks land on the host instead, so a "put a counter on target nonland - // permanent you control" trigger lands on the creature rather than the chosen - // Equipment, and an attached Equipment can't be re-activated to move it. Open - // a host's attachments whenever any of them is actionable in the current - // waiting state so each is independently clickable without requiring a hover. - const attachmentsActionable = - obj.attachments.length > 0 - && obj.attachments.some( - (id) => - validTargetObjectIds.has(id) - || activatableObjectIds.has(id) - || manaTappableObjectIds.has(id) - || boardChoiceObjectIds.has(id) - || selectableSacrificeObjectIds.has(id) - || selectableManaCostCreatureIds.has(id) - // An attachment tapped for mana that can still be untapped (undo) is - // itself actionable — keep it expanded so the undo affordance stays - // clickable. `undoableTapObjectIds` is already gated upstream - // (GameBoard `undoLegal`) to the states whose engine match arms accept - // the untap, so no extra state check is needed here. - || undoableTapObjectIds.has(id), - ); + // The viewer-scoped engine projection owns both the direct-attachment + // relationship and whether one is actionable for this interaction. The + // board must not rediscover either fact from the raw snapshot. + const attachmentsActionable = interactionAttachmentFan !== null; const attachmentsLifted = obj.attachments.length > 0 && (attachmentsLiftedByAncestor || isInHoveredAttachmentTree); diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index 4760e0215d..a9c1193fa6 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -1,4 +1,5 @@ import type { BatchResolveResult, EngineAdapter, EngineSnapshot, GameAction, GameEvent, GameLogEntry, GameState, WaitingFor } from "../adapter/types"; +import type { InteractionSubmission } from "../adapter/generated/interaction"; import { AdapterError, AdapterErrorCode } from "../adapter/types"; import { attemptStateRehydrate, isEnginePanic, notifyEngineLost, routePanic } from "./engineRecovery"; import { normalizeEvents } from "../animation/eventNormalizer"; @@ -707,6 +708,29 @@ export function dispatchAction( return dispatchActionInternal(action, actor, null); } +/** + * Submit an engine-authored interaction response through the same adapter and + * atomic snapshot boundary used by ordinary game actions. The response is + * opaque: UI callers cannot materialize or reinterpret a GameAction. + */ +export async function dispatchInteraction( + submission: InteractionSubmission, + actor: number = getPlayerId(), +): Promise { + const { adapter, gameState, gameMode } = useGameStore.getState(); + if (!adapter || !gameState || gameMode === "spectate" || actor === SPECTATOR_PLAYER_ID) return; + if (!adapter.submitInteraction) { + throw new AdapterError("WS_ERROR", "This game connection does not support interaction responses", false); + } + + const result = await adapter.submitInteraction(submission, actor); + const snapshot = await adapter.getSnapshot(); + useGameStore.getState().commitEngineSnapshot(snapshot, { + events: result.events, + logEntries: result.log_entries ?? [], + }); +} + /** Dispatch a standing preference only while its captured game lifecycle is * still current. A late response from a disposed or resumed session is dropped * before snapshot fetch/commit, so it cannot overwrite the replacement game. */ diff --git a/client/src/network/protocol.ts b/client/src/network/protocol.ts index 6ac53d31dc..46808bdaae 100644 --- a/client/src/network/protocol.ts +++ b/client/src/network/protocol.ts @@ -9,7 +9,7 @@ import type { ObjectId, ObjectAction, } from "../adapter/types"; -import type { ViewerInteraction } from "../adapter/generated/interaction"; +import type { InteractionSubmission, ViewerInteraction } from "../adapter/generated/interaction"; import type { SeatMutation, SeatView } from "../multiplayer/seatTypes"; /** @@ -85,7 +85,7 @@ export function legalActionsFromWire(wire: LegalActionsWire): LegalActionsResult * sub-phase on WaitingFor::MulliganDecision; the MulliganBottomCards * variant was removed */ -export const WIRE_PROTOCOL_VERSION = 15 as const; +export const WIRE_PROTOCOL_VERSION = 16 as const; export type P2PMessage = | { type: "guest_deck"; deckData: unknown; displayName?: string; reservationToken?: string } @@ -99,6 +99,7 @@ export type P2PMessage = playerNames?: Record; } & LegalActionsWire) | { type: "action"; senderPlayerId: number; action: GameAction } + | { type: "interaction"; senderPlayerId: number; submission: InteractionSubmission } | { type: "preview_mana_payment"; requestId: number; action: GameAction } | ({ type: "state_update"; @@ -156,6 +157,7 @@ const VALID_TYPES = new Set([ "guest_deck", "game_setup", "action", + "interaction", "preview_mana_payment", "state_update", "action_rejected", diff --git a/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts b/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts index e455b7abdc..f294c703c7 100644 --- a/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts +++ b/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts @@ -65,6 +65,7 @@ function interactionWith(choices: InteractionChoice[]): ViewerInteraction { }, }, ], + attachmentFans: [], availability: { type: "inputRequired" }, }; } diff --git a/client/src/wasm/engine_wasm.d.ts b/client/src/wasm/engine_wasm.d.ts index 9c956aa489..87c3571c54 100644 --- a/client/src/wasm/engine_wasm.d.ts +++ b/client/src/wasm/engine_wasm.d.ts @@ -449,6 +449,7 @@ export function signatureSpellSelectionPolicy(request: any): any; * applying the action as another player. */ export function submit_action(actor: number, action: any): any; +export function submit_interaction_js(actor: number, submission: any): any; /** * Drain the last captured panic message (consuming it). Returns `null` when @@ -507,6 +508,7 @@ export interface InitOutput { readonly sideboardPolicyForFormat: (a: any) => [number, number, number]; readonly signatureSpellSelectionPolicy: (a: any) => [number, number, number]; readonly submit_action: (a: number, b: any) => any; + readonly submit_interaction_js: (a: number, b: any) => any; readonly take_last_panic_message: () => [number, number]; readonly get_game_state: () => any; readonly get_legal_actions_js: () => any; diff --git a/crates/engine-wasm/src/lib.rs b/crates/engine-wasm/src/lib.rs index 9497964387..57392096e5 100644 --- a/crates/engine-wasm/src/lib.rs +++ b/crates/engine-wasm/src/lib.rs @@ -16,7 +16,7 @@ use engine::game::engine::{ apply, apply_for_simulation, resolve_all_fast_forward, ResolveAllCallbackDecision, ResolveAllFastForwardResult as BatchResolveResult, }; -use engine::game::interaction::bind_interaction_authority; +use engine::game::interaction::{bind_interaction_authority, submit_interaction}; use engine::game::preview::{compute_preview_diff, preview_auto_payment_sources}; use engine::game::{ can_pair_commanders, companion_candidates, deck_copy_limit_for, estimate_bracket, @@ -30,7 +30,7 @@ use engine::game::{ use engine::types::format::{DeckCopyLimit, FormatConfig, GameFormat}; use engine::types::game_state::{PersistedGameState, TrustedGameStateEnvelope, WaitingFor}; use engine::types::identifiers::ObjectId; -use engine::types::interaction::InteractionSessionId; +use engine::types::interaction::{InteractionSessionId, InteractionSubmission}; use engine::types::mana::ManaCost; use engine::types::match_config::MatchConfig; use engine::types::{GameAction, GameState, PlayerId, ReplayHeader, ReplayLog}; @@ -1026,6 +1026,30 @@ pub fn submit_action(actor: u8, action: JsValue) -> JsValue { } } +/// Submit one opaque, engine-authored interaction response. The browser never +/// materializes a `GameAction`; only a successful engine reducer result exposes +/// the exact action to the replay recorder. +#[wasm_bindgen] +pub fn submit_interaction_js(actor: u8, submission: JsValue) -> JsValue { + let submission: InteractionSubmission = match serde_wasm_bindgen::from_value(submission) { + Ok(submission) => submission, + Err(error) => { + return JsValue::from_str(&format!( + "Engine error: failed to deserialize interaction submission: {error}" + )); + } + }; + let actor = PlayerId(actor); + match with_state_mut(|state| submit_interaction(state, actor, submission)) { + Ok(Ok(applied)) => { + record_replay_action(false, actor, applied.action); + to_js(&applied.result) + } + Ok(Err(error)) => JsValue::from_str(&format!("Engine error: {:?}", error.code)), + Err(error) => error, + } +} + /// Record a successfully-applied action into REPLAY_LOG, or invalidate any /// in-progress recording if it was a (non-CreateCard) debug action. /// diff --git a/crates/engine/src/bin/interaction_bindings.rs b/crates/engine/src/bin/interaction_bindings.rs index ca2e100e6f..85f4b50845 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -2,14 +2,15 @@ use std::path::{Path, PathBuf}; use engine::types::interaction::{ ActiveInteractionSlot, AggregateComparator, AmountAssignment, ConfirmSemantics, - InteractionActionCode, InteractionAggregateFunction, InteractionAvailability, - InteractionChoice, InteractionChoiceStatus, InteractionDamageAssignmentMode, - InteractionGroupConstraint, InteractionIntentCode, InteractionManaAbilityActivationScope, - InteractionManaColor, InteractionManaComparator, InteractionManaRestriction, - InteractionManaSpecialAction, InteractionManaSpellCostCriterion, - InteractionManaZoneSpendPolarity, InteractionObjectProperty, InteractionOpportunity, - InteractionOpportunityResponse, InteractionOutcomeCode, InteractionPresentationSurface, - InteractionPreview, InteractionPreviewRequest, InteractionPreviewStatus, InteractionProgress, + InteractionActionCode, InteractionAggregateFunction, InteractionAttachmentFan, + InteractionAttachmentFanChild, InteractionAvailability, InteractionChoice, + InteractionChoiceStatus, InteractionDamageAssignmentMode, InteractionGroupConstraint, + InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, + InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, + InteractionManaSpellCostCriterion, InteractionManaZoneSpendPolarity, InteractionObjectProperty, + InteractionObjectReference, InteractionOpportunity, InteractionOpportunityResponse, + InteractionOutcomeCode, InteractionPresentationSurface, InteractionPreview, + InteractionPreviewRequest, InteractionPreviewStatus, InteractionProgress, InteractionReasonCode, InteractionRelation, InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionShortcutCountSpec, InteractionShortcutDecision, @@ -41,6 +42,7 @@ fn expected_bindings() -> String { "InteractionChoiceId", "InteractionActionId", "PreviewRequestId", + "InteractionObjectReference", ] { output.push_str(&format!( "export type {name} = string & {{ readonly __brand: \"{name}\" }};\n\n" @@ -97,6 +99,8 @@ fn expected_bindings() -> String { InteractionOpportunityResponse, InteractionProgress, InteractionOpportunity, + InteractionAttachmentFanChild, + InteractionAttachmentFan, InteractionAvailability, ViewerInteraction, AmountAssignment, diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index d04c96feaa..27e4d2a573 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -1,8 +1,7 @@ //! Hidden engine-authority interaction projection and submission boundary. //! -//! No production transport calls this module yet. The existing human action UI -//! remains the only exposed authority until the separately reviewed adapter -//! cutover. Engine tests may use these entry points to prove the contract. +//! Production adapters consume this projection while the existing action UI +//! remains the exposed authority until its separately reviewed cutover. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -38,16 +37,17 @@ use crate::types::identifiers::ObjectId; use crate::types::interaction::{ ActiveInteractionSlot, AggregateComparator, AmountAssignment, ConfirmSemantics, InteractionActionCode, InteractionActionId, InteractionAggregateFunction, - InteractionAvailability, InteractionChoice, InteractionChoiceId, InteractionChoiceStatus, + InteractionAttachmentFan, InteractionAttachmentFanChild, InteractionAvailability, + InteractionChoice, InteractionChoiceId, InteractionChoiceStatus, InteractionDamageAssignmentMode, InteractionGroupConstraint, InteractionId, InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, InteractionManaSpellCostCriterion, InteractionManaZoneSpendPolarity, InteractionObjectProperty, - InteractionOpportunity, InteractionOpportunityResponse, InteractionOutcomeCode, - InteractionPresentationSurface, InteractionPreview, InteractionPreviewRequest, - InteractionPreviewStatus, InteractionProgress, InteractionReasonCode, - InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, - InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, + InteractionObjectReference, InteractionOpportunity, InteractionOpportunityResponse, + InteractionOutcomeCode, InteractionPresentationSurface, InteractionPreview, + InteractionPreviewRequest, InteractionPreviewStatus, InteractionProgress, + InteractionReasonCode, InteractionRelationConstraint, InteractionRelationSourceConstraint, + InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPoint, InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, @@ -67,7 +67,7 @@ use super::dungeon::DungeonId; use super::engine::{ apply_interaction, apply_interaction_for_simulation, EngineError, MAX_SHORTCUT_CYCLES, }; -use super::game_object::RoomDoor; +use super::game_object::{AttachTarget, RoomDoor}; use super::merge::MergeSide; use super::{mana_sources, turn_control, visibility}; @@ -7182,6 +7182,7 @@ pub fn derive_viewer_interaction( can_submit: false, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: Vec::new(), availability: InteractionAvailability::Terminal { outcome: InteractionOutcomeCode::Terminal, }, @@ -7197,6 +7198,7 @@ pub fn derive_viewer_interaction( can_submit: false, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: Vec::new(), availability: InteractionAvailability::Waiting, }; } @@ -7214,6 +7216,7 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: Vec::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::AuthorityUnbound, }, @@ -7229,6 +7232,7 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: Vec::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::InvalidAuthorityState, }, @@ -7255,12 +7259,14 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), + attachment_fans: Vec::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::PayloadTooLarge, }, }; } let mut opportunities = Vec::with_capacity(slots.len()); + let mut attachment_fans = Vec::new(); let mut first_progress = None; let mut first_fallback = None; let default_availability = InteractionAvailability::Stuck { @@ -7269,9 +7275,22 @@ pub fn derive_viewer_interaction( for slot in slots { let (mut opportunity, mut slot_availability) = opportunity_for_slot(authoritative_state, filtered_state, viewer, slot); - if bound_outbound_opportunity(&opportunity).is_err() { + let opportunity_is_bounded = bound_outbound_opportunity(&opportunity).is_ok(); + if !opportunity_is_bounded { (opportunity, slot_availability) = payload_too_large_opportunity(&slot.interaction_id); } + if opportunity_is_bounded + && !matches!( + slot_availability, + InteractionAvailability::Unsupported { .. } + ) + { + attachment_fans.extend(attachment_fans_for_slot( + authoritative_state, + filtered_state, + slot, + )); + } if matches!( slot_availability, InteractionAvailability::ProgressAvailable { .. } @@ -7299,10 +7318,12 @@ pub fn derive_viewer_interaction( WaitingFor::Priority { .. } ) && authoritative_state.auto_pass.contains_key(&viewer), opportunities, + attachment_fans, availability, }; if bound_outbound_view(&view).is_err() { view.opportunities.clear(); + view.attachment_fans.clear(); view.availability = InteractionAvailability::Unsupported { reason: InteractionReasonCode::PayloadTooLarge, }; @@ -7310,11 +7331,141 @@ pub fn derive_viewer_interaction( view } +/// Build attachment affordances from typed decision provenance. The host +/// back-link and child forward-link must agree; this avoids surfacing stale +/// relationship data or indirect descendants. +fn attachment_fans_for_slot( + authoritative_state: &GameState, + filtered_state: &GameState, + slot: &ActiveInteractionSlot, +) -> Vec { + let semantic_owner = PlayerId(slot.semantic_owner); + let object_choices = match human_response_model(&filtered_state.waiting_for, semantic_owner) { + HumanResponseModel::TargetSequence => { + target_sequence_projection(&filtered_state.waiting_for) + .ok() + .flatten() + .into_iter() + .flat_map(|projection| { + projection + .candidates + .into_iter() + .enumerate() + .filter_map(|(index, target)| match target { + TargetRef::Object(object_id) => Some(( + object_id, + interaction_choice_id(&slot.interaction_id, 't', index), + )), + TargetRef::Player(_) => None, + }) + .collect::>() + }) + .collect::>() + } + HumanResponseModel::Select => { + selection_projection(&filtered_state.waiting_for, filtered_state, semantic_owner) + .ok() + .flatten() + .into_iter() + .flat_map(|projection| { + projection + .object_ids + .into_iter() + .enumerate() + .map(|(index, object_id)| { + ( + object_id, + interaction_choice_id(&slot.interaction_id, 's', index), + ) + }) + .collect::>() + }) + .collect::>() + } + HumanResponseModel::ExactCandidates(AuditedExactCandidates) => { + actor_candidates(authoritative_state, semantic_owner) + .unwrap_or_default() + .into_iter() + .enumerate() + .filter_map(|(index, candidate)| { + candidate.action.source_object().map(|object_id| { + ( + object_id, + interaction_choice_id(&slot.interaction_id, 'c', index), + ) + }) + }) + .collect() + } + _ => Vec::new(), + }; + attachment_fans_for_object_choices(filtered_state, &slot.interaction_id, object_choices) +} + +fn attachment_fans_for_object_choices( + filtered_state: &GameState, + interaction_id: &InteractionId, + object_choices: impl IntoIterator, +) -> Vec { + let mut fans: BTreeMap< + (InteractionId, ObjectId), + BTreeMap>, + > = BTreeMap::new(); + + for (child_id, choice_id) in object_choices { + let Some(child) = filtered_state.objects.get(&child_id) else { + continue; + }; + let Some(AttachTarget::Object(host_id)) = child.attached_to else { + continue; + }; + let Some(host) = filtered_state.objects.get(&host_id) else { + continue; + }; + if !host.attachments.contains(&child_id) { + continue; + } + let choice_ids = fans + .entry((interaction_id.clone(), host_id)) + .or_default() + .entry(child_id) + .or_default(); + if !choice_ids.contains(&choice_id) { + choice_ids.push(choice_id); + } + } + + fans.into_iter() + .map( + |((interaction_id, host), children)| InteractionAttachmentFan { + interaction_id, + host: InteractionObjectReference(host.0.to_string()), + children: children + .into_iter() + .map(|(object, choice_ids)| InteractionAttachmentFanChild { + object: InteractionObjectReference(object.0.to_string()), + choice_ids, + }) + .collect(), + }, + ) + .collect() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InteractionSubmitError { pub code: InteractionReasonCode, } +/// Successful interaction submission. The action is the exact opaque response +/// materialized by the engine and is available to trusted adapters solely for +/// post-success replay recording. +#[derive(Debug, Clone)] +pub struct AppliedInteraction { + pub action: GameAction, + pub result: ActionResult, +} + impl From for InteractionSubmitError { fn from(code: InteractionReasonCode) -> Self { Self { code } @@ -7596,6 +7747,19 @@ fn bound_outbound_view(view: &ViewerInteraction) -> Result<(), InteractionReason for opportunity in &view.opportunities { bound_outbound_opportunity_with_budget(opportunity, &mut budget)?; } + budget.list(view.attachment_fans.len())?; + for fan in &view.attachment_fans { + budget.string(fan.interaction_id.as_str())?; + budget.string(fan.host.as_str())?; + budget.list(fan.children.len())?; + for child in &fan.children { + budget.string(child.object.as_str())?; + budget.list(child.choice_ids.len())?; + for choice_id in &child.choice_ids { + budget.string(choice_id.as_str())?; + } + } + } if let InteractionAvailability::ProgressAvailable { witness } = &view.availability { budget.string(witness.interaction_id.as_str())?; bound_outbound_response(&witness.response, &mut budget)?; @@ -9074,7 +9238,7 @@ pub fn submit_interaction( state: &mut GameState, actor: PlayerId, submission: InteractionSubmission, -) -> Result { +) -> Result { let action = resolve_interaction_response(state, actor, &submission)?; // Re-read the slot rather than threading it out of `resolve_*`: keeping that // function's return to the action alone is what makes it usable as a public @@ -9082,9 +9246,10 @@ pub fn submit_interaction( // slot per pending decision, and it has already succeeded once here. let semantic_owner = PlayerId(slot_for_submission(state, actor, &submission.interaction_id)?.semantic_owner); - apply_interaction(state, actor, semantic_owner, action).map_err(|_error: EngineError| { - InteractionSubmitError { + let result = apply_interaction(state, actor, semantic_owner, action.clone()).map_err( + |_error: EngineError| InteractionSubmitError { code: InteractionReasonCode::ReducerRejected, - } - }) + }, + )?; + Ok(AppliedInteraction { action, result }) } diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index e0d6f4ecbe..06fc0f45b6 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -29,6 +29,9 @@ opaque_string_id!(InteractionId); opaque_string_id!(InteractionChoiceId); opaque_string_id!(InteractionActionId); opaque_string_id!(PreviewRequestId); +// Viewer-safe object reference. Only the engine maps this opaque interaction +// value back to an in-game object. +opaque_string_id!(InteractionObjectReference); /// Persistence slot semantics. Simultaneous pregame decisions deliberately /// retain one capability per semantic owner instead of sharing one global ID. @@ -1080,6 +1083,30 @@ pub struct InteractionOpportunity { pub progress: InteractionProgress, } +/// Direct attachment choices grouped under one visible host. Distinct choice +/// ids remain intact even when they describe the same attachment object. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionAttachmentFanChild { + pub object: InteractionObjectReference, + pub choice_ids: Vec, +} + +/// Viewer-scoped attachment affordance for a single interaction opportunity. +/// It is derived from the filtered projection, not by consumers scanning game +/// state that may carry authority-only relationship information. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] +pub struct InteractionAttachmentFan { + pub interaction_id: InteractionId, + pub host: InteractionObjectReference, + pub children: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] #[serde( @@ -1112,6 +1139,7 @@ pub struct ViewerInteraction { pub can_submit: bool, pub auto_pass_recommended: bool, pub opportunities: Vec, + pub attachment_fans: Vec, pub availability: InteractionAvailability, } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 2ef04b7dbb..6b14f8ae8e 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -317,8 +317,13 @@ fn resolving_a_response_materializes_the_advertised_action_under_the_same_author // live decision rather than one the engine would have refused anyway. // Equivalence between the two paths needs no assertion: `submit_interaction` // delegates here, so they cannot disagree. - submit_interaction(&mut state, P0, witness) + let applied = submit_interaction(&mut state, P0, witness) .expect("the witness the projection advertised is submittable"); + assert_eq!( + applied.action, + GameAction::PassPriority, + "the post-success transaction exposes the exact engine-materialized action for replay" + ); } #[test] @@ -389,6 +394,63 @@ fn priority_projection_previews_submits_and_rejects_stale_or_unauthorized_ids() assert_eq!(stale.code, InteractionReasonCode::StaleInteraction); } +#[test] +fn attachment_fans_are_per_interaction_filtered_and_direct() { + let mut scenario = GameScenario::new(); + scenario.at_phase(Phase::PreCombatMain); + let host = scenario.add_creature(P0, "Fan Host", 2, 2).id(); + let attachment = scenario.add_creature(P0, "Fan Attachment", 1, 1).id(); + let unrelated = scenario.add_creature(P0, "Fan Unrelated", 1, 1).id(); + let mut runner = scenario.build(); + { + let state = runner.state_mut(); + engine::game::effects::attach::attach_to(state, attachment, host); + state.objects.get_mut(&attachment).unwrap().tapped = true; + state.objects.get_mut(&unrelated).unwrap().tapped = true; + state.waiting_for = WaitingFor::ChooseUntapSubset { + player: P0, + group: vec![attachment, unrelated], + max: 1, + }; + bind(state, "attachment-fan"); + } + + let view = viewer_interaction(runner.state(), P0); + let opportunity = view + .opportunities + .first() + .expect("reach guard: the selected attachment has a live opportunity"); + assert_eq!(view.attachment_fans.len(), 1); + let fan = &view.attachment_fans[0]; + assert_eq!(fan.interaction_id, opportunity.interaction_id); + assert_eq!(fan.host.as_str(), host.0.to_string()); + assert_eq!(fan.children.len(), 1); + assert_eq!(fan.children[0].object.as_str(), attachment.0.to_string()); + assert!( + !fan.children[0].choice_ids.is_empty(), + "reach guard: the attachment fan keeps an opaque engine choice for its child" + ); + + let mut mismatched_filtered = filter_state_for_viewer(runner.state(), P0); + mismatched_filtered + .objects + .get_mut(&host) + .expect("fixture host remains visible") + .attachments + .clear(); + let mismatched = derive_viewer_interaction(runner.state(), &mismatched_filtered, P0); + assert!( + mismatched.attachment_fans.is_empty(), + "a stale host back-link must not expose an attachment fan from authoritative state" + ); + + let unauthorized = viewer_interaction(runner.state(), P1); + assert!( + unauthorized.attachment_fans.is_empty(), + "non-authorized viewers receive no attachment sidecar before any opportunity derivation" + ); +} + #[test] fn authority_requires_explicit_binding_and_rebinding_invalidates_old_capabilities() { let mut state = GameState::new_two_player(42); From a9e95ae5f26854e0f3a082a798a206e4f447fcbc Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:21:40 -0700 Subject: [PATCH 02/10] fix(interaction): preserve viewer projection compatibility --- client/src/components/board/AttachmentFan.tsx | 8 -------- client/src/components/board/PermanentCard.tsx | 1 - crates/engine/src/bin/interaction_bindings.rs | 17 ++++++++--------- crates/engine/src/types/interaction.rs | 1 + 4 files changed, 9 insertions(+), 18 deletions(-) diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index 0457dc1351..14433d3843 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -13,14 +13,6 @@ import { dispatchInteraction } from "../../game/dispatch.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; -import { collectObjectActions } from "../../viewmodel/cardActionChoice.ts"; -import { - boardChoiceMaxSelection, - buildBoardChoiceAction, - canConfirmBoardChoice, - getBoardChoiceView, - isBoardChoiceImmediate, -} from "../../viewmodel/gameStateView.ts"; import { CardImage } from "../card/CardImage.tsx"; import { fanGeometry, spreadFactor } from "../card/fanGeometry.ts"; diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index 0ce238bb5e..739bf1be55 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -342,7 +342,6 @@ export const PermanentCard = memo(function PermanentCard({ incomingAttackerCounts, manaTappableObjectIds, selectableManaCostCreatureIds, - selectableSacrificeObjectIds, undoableTapObjectIds, validAttackerIds, validTargetObjectIds, diff --git a/crates/engine/src/bin/interaction_bindings.rs b/crates/engine/src/bin/interaction_bindings.rs index 85f4b50845..7cf60696bc 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -8,15 +8,14 @@ use engine::types::interaction::{ InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, InteractionManaSpellCostCriterion, InteractionManaZoneSpendPolarity, InteractionObjectProperty, - InteractionObjectReference, InteractionOpportunity, InteractionOpportunityResponse, - InteractionOutcomeCode, InteractionPresentationSurface, InteractionPreview, - InteractionPreviewRequest, InteractionPreviewStatus, InteractionProgress, - InteractionReasonCode, InteractionRelation, InteractionRelationConstraint, - InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, - InteractionRoleCode, InteractionShortcutCountSpec, InteractionShortcutDecision, - InteractionShortcutPin, InteractionShortcutPoint, InteractionShortcutPointKind, - InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, - InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, + InteractionOpportunity, InteractionOpportunityResponse, InteractionOutcomeCode, + InteractionPresentationSurface, InteractionPreview, InteractionPreviewRequest, + InteractionPreviewStatus, InteractionProgress, InteractionReasonCode, InteractionRelation, + InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, + InteractionResponseSpec, InteractionRoleCode, InteractionShortcutCountSpec, + InteractionShortcutDecision, InteractionShortcutPin, InteractionShortcutPoint, + InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, + InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, InteractionWaitingForKind, InteractionZoneCode, SelectionConstraint, SimultaneousDecisionKind, ViewerInteraction, }; diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 06fc0f45b6..694a0428bb 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -1139,6 +1139,7 @@ pub struct ViewerInteraction { pub can_submit: bool, pub auto_pass_recommended: bool, pub opportunities: Vec, + #[serde(default)] pub attachment_fans: Vec, pub availability: InteractionAvailability, } From 1d26c858ce1b03a7cb0f2a03c9ac042aa3510baa Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:25:09 -0700 Subject: [PATCH 03/10] test(client): include attachment fan projection --- client/src/adapter/__tests__/server-draft-adapter.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index d99cc5901d..3b12367dfb 100644 --- a/client/src/adapter/__tests__/server-draft-adapter.test.ts +++ b/client/src/adapter/__tests__/server-draft-adapter.test.ts @@ -109,6 +109,7 @@ const viewerInteraction = { canSubmit: true, autoPassRecommended: false, opportunities: [], + attachmentFans: [], availability: { type: "inputRequired" }, } as LegalActionsResult["viewerInteraction"]; From 56a3cbcf2378cd9c0339d2c916871b9ebbc7f234 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:33:11 -0700 Subject: [PATCH 04/10] test(interaction): use attachment fan projection --- .../board/__tests__/PermanentCard.test.tsx | 97 ++++++++++++++----- client/src/network/__tests__/protocol.test.ts | 6 +- 2 files changed, 77 insertions(+), 26 deletions(-) diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index b67c260f0e..cf1006bab6 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -2,7 +2,13 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { GameAction, GameObject, GameState } from "../../../adapter/types.ts"; -import { dispatchAction } from "../../../game/dispatch.ts"; +import type { + InteractionChoiceId, + InteractionId, + InteractionObjectReference, + ViewerInteraction, +} from "../../../adapter/generated/interaction"; +import { dispatchAction, dispatchInteraction } from "../../../game/dispatch.ts"; import { useGameStore } from "../../../stores/gameStore.ts"; import { usePreferencesStore } from "../../../stores/preferencesStore.ts"; import { useUiStore } from "../../../stores/uiStore.ts"; @@ -22,6 +28,7 @@ import { PermanentCard } from "../PermanentCard.tsx"; vi.mock("../../../game/dispatch.ts", () => ({ dispatchAction: vi.fn(), + dispatchInteraction: vi.fn(), })); vi.mock("../../card/CardImage.tsx", () => ({ @@ -139,6 +146,41 @@ function renderPermanent( ); } +function interactionForAttachedObject(objectId: number): ViewerInteraction { + const interactionId = "attachment-interaction" as InteractionId; + const choiceId = `attachment-${objectId}` as InteractionChoiceId; + return { + waitingForKind: { simultaneous: null, terminal: false, code: "choose" }, + authorizedSubmitters: [0], + canSubmit: true, + autoPassRecommended: false, + opportunities: [{ + interactionId, + response: { + type: "exactChoices", + data: { + choices: [{ + id: choiceId, + status: { type: "available" }, + surfaces: [], + }], + }, + }, + surfaces: [], + progress: { selected: 0, minimum: 1, maximum: 1, aggregate: null, confirmable: false }, + }], + attachmentFans: [{ + interactionId, + host: "1" as InteractionObjectReference, + children: [{ + object: String(objectId) as InteractionObjectReference, + choiceIds: [choiceId], + }], + }], + availability: { type: "inputRequired" }, + }; +} + describe("PermanentCard", () => { beforeEach(() => { window.matchMedia = ((query: string) => ({ @@ -176,6 +218,7 @@ describe("PermanentCard", () => { tapRotation: "classic", }); vi.mocked(dispatchAction).mockClear(); + vi.mocked(dispatchInteraction).mockResolvedValue(); }); afterEach(() => { @@ -521,11 +564,15 @@ describe("PermanentCard", () => { gameState.objects[1].attachments = [2, 4]; gameState.objects[4] = secondEquipment; gameState.battlefield = [1, 2, 3, 4]; - useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObject(4), + }); // Attachment 4 is a valid target — both attachments must render even though // the host is neither hovered nor inspected. - const { container } = renderPermanent(new Set([4])); + const { container } = renderPermanent(); expect(container.querySelector('[data-object-id="2"]')).not.toBeNull(); expect(container.querySelector('[data-object-id="4"]')).not.toBeNull(); @@ -564,10 +611,14 @@ describe("PermanentCard", () => { }, }); gameState.waiting_for = waitingFor; - useGameStore.setState({ gameState, waitingFor }); + useGameStore.setState({ + gameState, + waitingFor, + viewerInteraction: interactionForAttachedObject(4), + }); useUiStore.setState({ attachmentFanHostId: null }); - const { container } = renderPermanent(new Set([4])); + const { container } = renderPermanent(); render(); fireEvent.click(container.querySelector('[data-object-id="1"]') as HTMLElement); @@ -579,9 +630,12 @@ describe("PermanentCard", () => { fireEvent.click(darksteelCard); - expect(dispatchAction).toHaveBeenCalledWith({ - type: "ChooseTarget", - data: { target: { Object: 4 } }, + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { + type: "choose", + data: { choiceId: "attachment-4" }, + }, }); }); @@ -606,14 +660,13 @@ describe("PermanentCard", () => { gameState.objects[1].attachments = [2, 4]; gameState.objects[4] = secondEquipment; gameState.battlefield = [1, 2, 3, 4]; - useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObject(4), + }); - const { container } = renderPermanent( - new Set(), - new Set(), - new Set(), - new Set([4]), - ); + const { container } = renderPermanent(); expect(container.querySelector('[data-object-id="2"]')).not.toBeNull(); expect(container.querySelector('[data-object-id="4"]')).not.toBeNull(); @@ -640,15 +693,13 @@ describe("PermanentCard", () => { gameState.objects[1].attachments = [2, 4]; gameState.objects[4] = secondEquipment; gameState.battlefield = [1, 2, 3, 4]; - useGameStore.setState({ gameState, waitingFor: gameState.waiting_for }); + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObject(4), + }); - const { container } = renderPermanent( - new Set(), - new Set(), - new Set(), - new Set(), - new Set([4]), - ); + const { container } = renderPermanent(); expect(container.querySelector('[data-object-id="2"]')).not.toBeNull(); expect(container.querySelector('[data-object-id="4"]')).not.toBeNull(); diff --git a/client/src/network/__tests__/protocol.test.ts b/client/src/network/__tests__/protocol.test.ts index be50a70c0f..8ba5036ed3 100644 --- a/client/src/network/__tests__/protocol.test.ts +++ b/client/src/network/__tests__/protocol.test.ts @@ -36,11 +36,11 @@ const viewerInteractionWithProducedMana = { } as never; describe("encodeWireMessage / decodeWireMessage", () => { - it("pins the P2P wire protocol to v15", () => { - expect(WIRE_PROTOCOL_VERSION).toBe(15); + it("pins the P2P wire protocol to v16", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(16); }); - it("defaults shortcut actions for a v15 payload created before the additive field", () => { + it("defaults shortcut actions for a legacy payload created before the additive field", () => { expect(legalActionsFromWire({ legalActions: [] }).manaPaymentShortcutActions).toEqual([]); }); From ecc945397567b671da01b4b7e0cb827b42ba6c1e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:42:29 -0700 Subject: [PATCH 05/10] fix(interaction): publish direct attachment submissions --- .../__tests__/server-draft-adapter.test.ts | 2 +- .../adapter/generated/interaction/index.ts | 6 +- client/src/adapter/types.ts | 1 + client/src/components/board/AttachmentFan.tsx | 143 +++--------------- client/src/components/board/PermanentCard.tsx | 2 +- .../board/__tests__/PermanentCard.test.tsx | 17 ++- client/src/game/dispatch.ts | 6 +- .../GamePage.projectedManaChoices.test.ts | 2 +- crates/engine/src/game/interaction.rs | 105 +++++++++---- crates/engine/src/types/interaction.rs | 19 ++- .../tests/integration/interaction_contract.rs | 26 ++-- 11 files changed, 145 insertions(+), 184 deletions(-) diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index 3b12367dfb..f3dc81039c 100644 --- a/client/src/adapter/__tests__/server-draft-adapter.test.ts +++ b/client/src/adapter/__tests__/server-draft-adapter.test.ts @@ -109,7 +109,7 @@ const viewerInteraction = { canSubmit: true, autoPassRecommended: false, opportunities: [], - attachmentFans: [], + attachmentFans: {}, availability: { type: "inputRequired" }, } as LegalActionsResult["viewerInteraction"]; diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 55332187ef..0a19db6104 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -101,13 +101,13 @@ export type InteractionProgress = { selected: number, minimum: number, maximum: export type InteractionOpportunity = { interactionId: InteractionId, response: InteractionOpportunityResponse, surfaces: Array, progress: InteractionProgress, }; -export type InteractionAttachmentFanChild = { object: InteractionObjectReference, choiceIds: Array, }; +export type InteractionAttachmentFanChild = { objectId: number, submission: InteractionSubmission, }; -export type InteractionAttachmentFan = { interactionId: InteractionId, host: InteractionObjectReference, children: Array, }; +export type InteractionAttachmentFan = { hostId: number, children: Array, }; export type InteractionAvailability = { "type": "progressAvailable", "data": { witness: InteractionSubmission, } } | { "type": "inputRequired" } | { "type": "escapeOnly", "data": { reason: InteractionReasonCode, } } | { "type": "waiting" } | { "type": "terminal", "data": { outcome: InteractionOutcomeCode, } } | { "type": "unsupported", "data": { reason: InteractionReasonCode, } } | { "type": "stuck", "data": { reason: InteractionReasonCode, } }; -export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: Array, availability: InteractionAvailability, }; +export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: { [key: number]: InteractionAttachmentFan }, availability: InteractionAvailability, }; export type AmountAssignment = { choiceId: InteractionChoiceId, amount: number, }; diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts index efea4a6ae9..5f1d9cea17 100644 --- a/client/src/adapter/types.ts +++ b/client/src/adapter/types.ts @@ -3237,6 +3237,7 @@ export const AdapterErrorCode = { * original dispatch. */ ENGINE_UNRESPONSIVE: "ENGINE_UNRESPONSIVE", + UNSUPPORTED: "UNSUPPORTED", WASM_ERROR: "WASM_ERROR", INVALID_ACTION: "INVALID_ACTION", DECK_REJECTED: "DECK_REJECTED", diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index 14433d3843..f1c6a361e9 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -1,14 +1,9 @@ -import { type CSSProperties, useCallback, useEffect, useMemo, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useMemo } from "react"; import { createPortal } from "react-dom"; import { motion } from "framer-motion"; import { useTranslation } from "react-i18next"; import type { ObjectId } from "../../adapter/types.ts"; -import type { - InteractionChoiceId, - InteractionOpportunity, - InteractionResponse, -} from "../../adapter/generated/interaction"; import { dispatchInteraction } from "../../game/dispatch.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; import { useGameStore } from "../../stores/gameStore.ts"; @@ -45,17 +40,6 @@ function fanCardSizingStyle(cardCount: number): CSSProperties { } as CSSProperties; } -/** - * Per-object selection state for one card in the fan, derived once by the - * parent from the live prompt so each card knows exactly which engine action - * (if any) its click should dispatch. Target > board-choice > activation is - * the same precedence PermanentCard uses on the battlefield. - */ -interface CardChoice { - choiceId: InteractionChoiceId | null; - isSelected: boolean; -} - /** * Centered spread of a host permanent plus every permanent attached to it * (Aura / Equipment / Fortification), fanned out at HAND size using the shared @@ -69,8 +53,9 @@ interface CardChoice { * * The fan NEVER invents a choice — each card lights up (cyan) and dispatches * only what the engine's live prompt actually offers for that object. Terminal - * picks (a target, an immediate board-choice, an activation) close the fan; - * a multi-select board-choice toggles and is finished via the Confirm button. + * One-step picks close the fan. Multi-step decisions stay in their dedicated + * engine-authored interaction surfaces instead of asking this display to build + * a response payload. * Direct clicking on the battlefield still works — this fan is an opt-in * convenience opened from the "⧉" badge, not a forced modal. */ @@ -82,16 +67,12 @@ export function AttachmentFan() { const objects = useGameStore((s) => s.gameState?.objects); const viewerInteraction = useGameStore((s) => s.viewerInteraction); - const [selectedChoiceIds, setSelectedChoiceIds] = useState([]); - const host = hostId != null ? objects?.[hostId] : undefined; const interactionFan = useMemo( () => hostId == null ? null - : (viewerInteraction?.attachmentFans.find( - (fan) => Number(fan.host) === hostId, - ) ?? null), + : (viewerInteraction?.attachmentFans[hostId] ?? null), [hostId, viewerInteraction], ); @@ -103,26 +84,11 @@ export function AttachmentFan() { ? [ host.id, ...(interactionFan - ? interactionFan.children.map((child) => Number(child.object)) + ? interactionFan.children.map((child) => child.objectId) : host.attachments), ] : []; - const opportunity = useMemo( - () => - interactionFan - ? (viewerInteraction?.opportunities.find( - (candidate) => candidate.interactionId === interactionFan.interactionId, - ) ?? null) - : null, - [interactionFan, viewerInteraction], - ); - const requiresConfirmation = - opportunity?.response.type === "schema" && - (opportunity.response.data.spec.type === "select" || - opportunity.response.data.spec.type === "sequence") && - opportunity.response.data.spec.data.confirm === "explicit"; - const close = useCallback(() => { setAttachmentFanHost(null); // The fan is opened from a hovered card, so a card preview may still be up @@ -140,40 +106,15 @@ export function AttachmentFan() { return () => window.removeEventListener("keydown", onKey); }, [hostId, close]); - const choiceFor = useCallback( - (id: ObjectId): CardChoice => { - const choiceIds = interactionFan?.children.find((child) => Number(child.object) === id)?.choiceIds ?? []; - const choiceId = choiceIds[0] ?? null; - return { - choiceId, - isSelected: choiceId !== null && selectedChoiceIds.includes(choiceId), - }; - }, - [interactionFan, selectedChoiceIds], - ); - const handlePick = useCallback( - (_id: ObjectId, choice: CardChoice) => { - if (!choice.choiceId || !opportunity || !viewerInteraction?.canSubmit) return; - if (requiresConfirmation) { - setSelectedChoiceIds((selected) => - selected.includes(choice.choiceId!) - ? selected.filter((id) => id !== choice.choiceId) - : [...selected, choice.choiceId!], - ); - return; - } - const response = responseForChoices(opportunity, [choice.choiceId]); - if (!response) return; - void dispatchInteraction({ interactionId: opportunity.interactionId, response }).then(close).catch(() => {}); + (id: ObjectId) => { + const child = interactionFan?.children.find((candidate) => candidate.objectId === id); + if (!child || !viewerInteraction?.canSubmit) return; + void dispatchInteraction(child.submission).then(close).catch(() => {}); }, - [close, opportunity, requiresConfirmation, viewerInteraction?.canSubmit], + [close, interactionFan, viewerInteraction?.canSubmit], ); - const confirmSelection = requiresConfirmation && opportunity - ? responseForChoices(opportunity, selectedChoiceIds) - : null; - if (hostId == null || !host || cardIds.length === 0) return null; // Shared compact whole-row fan — sized by the total card count so the host + @@ -203,70 +144,37 @@ export function AttachmentFan() { ))} - {confirmSelection && opportunity && ( - - )} , document.body, ); } -function responseForChoices( - opportunity: InteractionOpportunity, - choiceIds: InteractionChoiceId[], -): InteractionResponse | null { - if (opportunity.response.type === "exactChoices") { - return choiceIds.length === 1 ? { type: "choose", data: { choiceId: choiceIds[0] } } : null; - } - switch (opportunity.response.data.spec.type) { - case "select": - return { type: "select", data: { choiceIds } }; - case "sequence": - return { type: "sequence", data: { choiceIds } }; - default: - return null; - } -} - function FanCard({ objectId, - choice, marginLeft, rotation, arcOffset, zIndex, + selectable, onPick, }: { objectId: ObjectId; - choice: CardChoice; marginLeft: string | number; rotation: number; arcOffset: number; zIndex: number; - onPick: (id: ObjectId, choice: CardChoice) => void; + selectable: boolean; + onPick: (id: ObjectId) => void; }) { const { t } = useTranslation("game"); const obj = useGameStore((s) => s.gameState?.objects[objectId]); @@ -274,17 +182,9 @@ function FanCard({ const lookup = cardImageLookup(obj); const isToken = obj.display_source === "Token"; - const selectable = choice.choiceId !== null; - // The whole fan speaks one "pick me" color — cyan — so a spread of a host and - // its attachments reads as a single chooser regardless of whether the engine - // is asking for a target, a board choice, or an activation. Selected (a - // toggled multi-select board choice) brightens and adds a check. - const ring = choice.isSelected - ? "ring-4 ring-cyan-300 shadow-[0_0_22px_7px_rgba(34,211,238,0.7),inset_0_0_18px_5px_rgba(34,211,238,0.35)]" - : selectable - ? "ring-2 ring-cyan-400 shadow-[0_0_16px_5px_rgba(34,211,238,0.55)]" - : ""; + // its attachments reads as one direct engine-authorized chooser. + const ring = selectable ? "ring-2 ring-cyan-400 shadow-[0_0_16px_5px_rgba(34,211,238,0.55)]" : ""; // Mirror the hand card's resting animation (arc + tilt) and hover lift so the // attachment fan feels identical to picking a card out of hand. @@ -297,7 +197,7 @@ function FanCard({ transition={{ duration: 0.2 }} onClick={(e) => { e.stopPropagation(); - if (selectable) onPick(objectId, choice); + if (selectable) onPick(objectId); }} aria-label={obj.name} className={`relative leading-[0] select-none ${selectable ? "cursor-pointer" : "cursor-default"}`} @@ -318,12 +218,7 @@ function FanCard({ className="!w-[var(--fan-card-w)] !h-[var(--fan-card-h)]" /> - {choice.isSelected && ( - - ✓ - - )} - {selectable && !choice.isSelected && ( + {selectable && ( {t("permanent.fanPick")} diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx index 739bf1be55..ff48188d4d 100644 --- a/client/src/components/board/PermanentCard.tsx +++ b/client/src/components/board/PermanentCard.tsx @@ -466,7 +466,7 @@ export const PermanentCard = memo(function PermanentCard({ const viewerInteraction = useGameStore((s) => s.viewerInteraction); const interactionAttachmentFan = useMemo( () => - viewerInteraction?.attachmentFans.find((fan) => Number(fan.host) === objectId) ?? null, + viewerInteraction?.attachmentFans[objectId] ?? null, [objectId, viewerInteraction], ); diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index cf1006bab6..8c821b86b2 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -5,7 +5,6 @@ import type { GameAction, GameObject, GameState } from "../../../adapter/types.t import type { InteractionChoiceId, InteractionId, - InteractionObjectReference, ViewerInteraction, } from "../../../adapter/generated/interaction"; import { dispatchAction, dispatchInteraction } from "../../../game/dispatch.ts"; @@ -169,14 +168,18 @@ function interactionForAttachedObject(objectId: number): ViewerInteraction { surfaces: [], progress: { selected: 0, minimum: 1, maximum: 1, aggregate: null, confirmable: false }, }], - attachmentFans: [{ - interactionId, - host: "1" as InteractionObjectReference, + attachmentFans: { + 1: { + hostId: 1, children: [{ - object: String(objectId) as InteractionObjectReference, - choiceIds: [choiceId], + objectId, + submission: { + interactionId, + response: { type: "choose", data: { choiceId } }, + }, }], - }], + }, + }, availability: { type: "inputRequired" }, }; } diff --git a/client/src/game/dispatch.ts b/client/src/game/dispatch.ts index a9c1193fa6..fc247a4106 100644 --- a/client/src/game/dispatch.ts +++ b/client/src/game/dispatch.ts @@ -720,7 +720,11 @@ export async function dispatchInteraction( const { adapter, gameState, gameMode } = useGameStore.getState(); if (!adapter || !gameState || gameMode === "spectate" || actor === SPECTATOR_PLAYER_ID) return; if (!adapter.submitInteraction) { - throw new AdapterError("WS_ERROR", "This game connection does not support interaction responses", false); + throw new AdapterError( + AdapterErrorCode.UNSUPPORTED, + "This game connection does not support interaction responses", + false, + ); } const result = await adapter.submitInteraction(submission, actor); diff --git a/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts b/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts index f294c703c7..083fd60d78 100644 --- a/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts +++ b/client/src/pages/__tests__/GamePage.projectedManaChoices.test.ts @@ -65,7 +65,7 @@ function interactionWith(choices: InteractionChoice[]): ViewerInteraction { }, }, ], - attachmentFans: [], + attachmentFans: {}, availability: { type: "inputRequired" }, }; } diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 27e4d2a573..f4c4b9270d 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -43,11 +43,11 @@ use crate::types::interaction::{ InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, InteractionManaSpellCostCriterion, InteractionManaZoneSpendPolarity, InteractionObjectProperty, - InteractionObjectReference, InteractionOpportunity, InteractionOpportunityResponse, - InteractionOutcomeCode, InteractionPresentationSurface, InteractionPreview, - InteractionPreviewRequest, InteractionPreviewStatus, InteractionProgress, - InteractionReasonCode, InteractionRelationConstraint, InteractionRelationSourceConstraint, - InteractionResponse, InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, + InteractionOpportunity, InteractionOpportunityResponse, InteractionOutcomeCode, + InteractionPresentationSurface, InteractionPreview, InteractionPreviewRequest, + InteractionPreviewStatus, InteractionProgress, InteractionReasonCode, + InteractionRelationConstraint, InteractionRelationSourceConstraint, InteractionResponse, + InteractionResponseSpec, InteractionRoleCode, InteractionSessionId, InteractionShortcutCountSpec, InteractionShortcutDecision, InteractionShortcutPoint, InteractionShortcutPointKind, InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, @@ -7182,7 +7182,7 @@ pub fn derive_viewer_interaction( can_submit: false, auto_pass_recommended: false, opportunities: Vec::new(), - attachment_fans: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Terminal { outcome: InteractionOutcomeCode::Terminal, }, @@ -7198,7 +7198,7 @@ pub fn derive_viewer_interaction( can_submit: false, auto_pass_recommended: false, opportunities: Vec::new(), - attachment_fans: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Waiting, }; } @@ -7216,7 +7216,7 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), - attachment_fans: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::AuthorityUnbound, }, @@ -7232,7 +7232,7 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), - attachment_fans: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::InvalidAuthorityState, }, @@ -7259,14 +7259,14 @@ pub fn derive_viewer_interaction( can_submit: true, auto_pass_recommended: false, opportunities: Vec::new(), - attachment_fans: Vec::new(), + attachment_fans: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::PayloadTooLarge, }, }; } let mut opportunities = Vec::with_capacity(slots.len()); - let mut attachment_fans = Vec::new(); + let mut attachment_fans = BTreeMap::new(); let mut first_progress = None; let mut first_fallback = None; let default_availability = InteractionAvailability::Stuck { @@ -7338,9 +7338,10 @@ fn attachment_fans_for_slot( authoritative_state: &GameState, filtered_state: &GameState, slot: &ActiveInteractionSlot, -) -> Vec { +) -> BTreeMap { let semantic_owner = PlayerId(slot.semantic_owner); - let object_choices = match human_response_model(&filtered_state.waiting_for, semantic_owner) { + let model = human_response_model(&filtered_state.waiting_for, semantic_owner); + let object_choices = match model { HumanResponseModel::TargetSequence => { target_sequence_projection(&filtered_state.waiting_for) .ok() @@ -7397,16 +7398,33 @@ fn attachment_fans_for_slot( }) .collect() } - _ => Vec::new(), + HumanResponseModel::Terminal + | HumanResponseModel::AssignAmounts + | HumanResponseModel::AmountAssignments + | HumanResponseModel::DamageAssignments + | HumanResponseModel::TriggerOrder + | HumanResponseModel::CoinFlipSequence + | HumanResponseModel::CategorySelection + | HumanResponseModel::CombatRelations(_) + | HumanResponseModel::ManaGroups(_) + | HumanResponseModel::ModeSequence + | HumanResponseModel::OutsideSelection + | HumanResponseModel::TextChoice + | HumanResponseModel::ShortcutReply + | HumanResponseModel::DirectChoices + | HumanResponseModel::SideboardPartition + | HumanResponseModel::NumberRange(_) + | HumanResponseModel::LoopShortcut => Vec::new(), }; - attachment_fans_for_object_choices(filtered_state, &slot.interaction_id, object_choices) + attachment_fans_for_object_choices(filtered_state, &slot.interaction_id, model, object_choices) } fn attachment_fans_for_object_choices( filtered_state: &GameState, interaction_id: &InteractionId, + model: HumanResponseModel, object_choices: impl IntoIterator, -) -> Vec { +) -> BTreeMap { let mut fans: BTreeMap< (InteractionId, ObjectId), BTreeMap>, @@ -7436,22 +7454,51 @@ fn attachment_fans_for_object_choices( } fans.into_iter() - .map( - |((interaction_id, host), children)| InteractionAttachmentFan { - interaction_id, - host: InteractionObjectReference(host.0.to_string()), - children: children - .into_iter() - .map(|(object, choice_ids)| InteractionAttachmentFanChild { - object: InteractionObjectReference(object.0.to_string()), - choice_ids, - }) - .collect(), - }, - ) + .filter_map(|((interaction_id, host_id), children)| { + let children = children + .into_iter() + .filter_map(|(object_id, choice_ids)| { + let [choice_id] = choice_ids.as_slice() else { + return None; + }; + attachment_fan_submission(&interaction_id, model, choice_id.clone()).map( + |submission| InteractionAttachmentFanChild { + object_id, + submission, + }, + ) + }) + .collect::>(); + (!children.is_empty()) + .then_some((host_id, InteractionAttachmentFan { host_id, children })) + }) .collect() } +/// Only publish one-step attachment picks. Multi-choice objects and response +/// families that require the UI to synthesize a payload stay in the normal +/// interaction surface until the engine exposes a dedicated picker model. +fn attachment_fan_submission( + interaction_id: &InteractionId, + model: HumanResponseModel, + choice_id: InteractionChoiceId, +) -> Option { + let response = match model { + HumanResponseModel::ExactCandidates(_) => InteractionResponse::Choose { choice_id }, + HumanResponseModel::Select => InteractionResponse::Select { + choice_ids: vec![choice_id], + }, + HumanResponseModel::TargetSequence => InteractionResponse::Sequence { + choice_ids: vec![choice_id], + }, + _ => return None, + }; + Some(InteractionSubmission { + interaction_id: interaction_id.clone(), + response, + }) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InteractionSubmitError { pub code: InteractionReasonCode, diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 694a0428bb..14c831a469 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -6,8 +6,12 @@ //! wire graph. All display text is supplied by consumers from the semantic codes //! below; the engine never places localized UI prose in this contract. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; +use super::identifiers::ObjectId; + pub const MAX_INTERACTION_LIST_LEN: usize = 10_000; macro_rules! opaque_string_id { @@ -1083,15 +1087,17 @@ pub struct InteractionOpportunity { pub progress: InteractionProgress, } -/// Direct attachment choices grouped under one visible host. Distinct choice -/// ids remain intact even when they describe the same attachment object. +/// A direct, engine-authored interaction submission for one attachment. +/// +/// The UI must echo this opaque response rather than deriving an action or a +/// response envelope from the opportunity schema. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] pub struct InteractionAttachmentFanChild { - pub object: InteractionObjectReference, - pub choice_ids: Vec, + pub object_id: ObjectId, + pub submission: InteractionSubmission, } /// Viewer-scoped attachment affordance for a single interaction opportunity. @@ -1102,8 +1108,7 @@ pub struct InteractionAttachmentFanChild { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] pub struct InteractionAttachmentFan { - pub interaction_id: InteractionId, - pub host: InteractionObjectReference, + pub host_id: ObjectId, pub children: Vec, } @@ -1140,7 +1145,7 @@ pub struct ViewerInteraction { pub auto_pass_recommended: bool, pub opportunities: Vec, #[serde(default)] - pub attachment_fans: Vec, + pub attachment_fans: BTreeMap, pub availability: InteractionAvailability, } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 6b14f8ae8e..d2ae5b1f09 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -416,19 +416,25 @@ fn attachment_fans_are_per_interaction_filtered_and_direct() { } let view = viewer_interaction(runner.state(), P0); - let opportunity = view - .opportunities - .first() - .expect("reach guard: the selected attachment has a live opportunity"); + assert!( + !view.opportunities.is_empty(), + "reach guard: the selected attachment has a live opportunity" + ); assert_eq!(view.attachment_fans.len(), 1); - let fan = &view.attachment_fans[0]; - assert_eq!(fan.interaction_id, opportunity.interaction_id); - assert_eq!(fan.host.as_str(), host.0.to_string()); + let fan = view + .attachment_fans + .get(&host) + .expect("the engine keys the fan by its visible host object"); + assert_eq!(fan.host_id, host); assert_eq!(fan.children.len(), 1); - assert_eq!(fan.children[0].object.as_str(), attachment.0.to_string()); + assert_eq!(fan.children[0].object_id, attachment); + let submission = fan.children[0].submission.clone(); + submit_interaction(runner.state_mut(), P0, submission).expect( + "the engine-authored fan submission resolves through production interaction dispatch", + ); assert!( - !fan.children[0].choice_ids.is_empty(), - "reach guard: the attachment fan keeps an opaque engine choice for its child" + !runner.state().objects[&attachment].tapped, + "the published attachment submission applies its selected untap" ); let mut mismatched_filtered = filter_state_for_viewer(runner.state(), P0); From 1d2f6602675b5b70e77e60f62ba30fa9029a152c Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:43:47 -0700 Subject: [PATCH 06/10] test(interaction): cover attachment fan submissions --- client/src/components/board/AttachmentFan.tsx | 11 +++- .../board/__tests__/PermanentCard.test.tsx | 60 ++++++++++++++++--- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx index f1c6a361e9..7e6a03d671 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import type { ObjectId } from "../../adapter/types.ts"; import { dispatchInteraction } from "../../game/dispatch.ts"; import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts"; +import { useAppNotificationStore } from "../../stores/appToastStore.ts"; import { useGameStore } from "../../stores/gameStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; import { CardImage } from "../card/CardImage.tsx"; @@ -64,6 +65,7 @@ export function AttachmentFan() { const hostId = useUiStore((s) => s.attachmentFanHostId); const setAttachmentFanHost = useUiStore((s) => s.setAttachmentFanHost); const dismissPreview = useUiStore((s) => s.dismissPreview); + const showNotification = useAppNotificationStore((s) => s.showNotification); const objects = useGameStore((s) => s.gameState?.objects); const viewerInteraction = useGameStore((s) => s.viewerInteraction); @@ -110,9 +112,14 @@ export function AttachmentFan() { (id: ObjectId) => { const child = interactionFan?.children.find((candidate) => candidate.objectId === id); if (!child || !viewerInteraction?.canSubmit) return; - void dispatchInteraction(child.submission).then(close).catch(() => {}); + void dispatchInteraction(child.submission).then(close).catch((error: unknown) => { + showNotification({ + title: t("actionError.title", { action: t("permanent.fanPick") }), + description: error instanceof Error ? error.message : t("actionError.unknownEngineError"), + }); + }); }, - [close, interactionFan, viewerInteraction?.canSubmit], + [close, interactionFan, showNotification, t, viewerInteraction?.canSubmit], ); if (hostId == null || !host || cardIds.length === 0) return null; diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index 8c821b86b2..e5228ae513 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -145,9 +145,9 @@ function renderPermanent( ); } -function interactionForAttachedObject(objectId: number): ViewerInteraction { +function interactionForAttachedObjects(objectIds: number[]): ViewerInteraction { const interactionId = "attachment-interaction" as InteractionId; - const choiceId = `attachment-${objectId}` as InteractionChoiceId; + const choiceId = (objectId: number) => `attachment-${objectId}` as InteractionChoiceId; return { waitingForKind: { simultaneous: null, terminal: false, code: "choose" }, authorizedSubmitters: [0], @@ -158,11 +158,11 @@ function interactionForAttachedObject(objectId: number): ViewerInteraction { response: { type: "exactChoices", data: { - choices: [{ - id: choiceId, + choices: objectIds.map((objectId) => ({ + id: choiceId(objectId), status: { type: "available" }, surfaces: [], - }], + })), }, }, surfaces: [], @@ -171,19 +171,23 @@ function interactionForAttachedObject(objectId: number): ViewerInteraction { attachmentFans: { 1: { hostId: 1, - children: [{ + children: objectIds.map((objectId) => ({ objectId, submission: { interactionId, - response: { type: "choose", data: { choiceId } }, + response: { type: "choose", data: { choiceId: choiceId(objectId) } }, }, - }], + })), }, }, availability: { type: "inputRequired" }, }; } +function interactionForAttachedObject(objectId: number): ViewerInteraction { + return interactionForAttachedObjects([objectId]); +} + describe("PermanentCard", () => { beforeEach(() => { window.matchMedia = ((query: string) => ({ @@ -642,6 +646,46 @@ describe("PermanentCard", () => { }); }); + it("submits each attachment's engine-authored response independently", () => { + const secondEquipment = makeObject({ + id: 4, + card_id: 400, + attached_to: { type: "Object", data: 1 }, + attachments: [], + name: "Second Equipment", + power: null, + toughness: null, + base_power: null, + base_toughness: null, + card_types: { supertypes: [], core_types: ["Artifact"], subtypes: ["Equipment"] }, + color: [], + base_color: [], + }); + const gameState = makeState(); + gameState.objects[1].attachments = [2, 4]; + gameState.objects[4] = secondEquipment; + gameState.battlefield = [1, 2, 3, 4]; + useGameStore.setState({ + gameState, + waitingFor: gameState.waiting_for, + viewerInteraction: interactionForAttachedObjects([2, 4]), + }); + useUiStore.setState({ attachmentFanHostId: 1 }); + render(); + + fireEvent.click(screen.getByLabelText("Test Equipment")); + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { type: "choose", data: { choiceId: "attachment-2" } }, + }); + + fireEvent.click(screen.getByLabelText("Second Equipment")); + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { type: "choose", data: { choiceId: "attachment-4" } }, + }); + }); + it("auto-expands collapsed attachments when one is activatable (re-equip)", () => { // Regression: an attached Equipment whose Equip ability is activatable must // be reachable so it can be moved to another creature. Collapsed behind the From 0a091187c721e5204c953011088899fb52e186df Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:46:44 -0700 Subject: [PATCH 07/10] fix(interaction): bound direct fan submissions --- crates/engine/src/game/interaction.rs | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index f4c4b9270d..6d981af2cb 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -7795,16 +7795,11 @@ fn bound_outbound_view(view: &ViewerInteraction) -> Result<(), InteractionReason bound_outbound_opportunity_with_budget(opportunity, &mut budget)?; } budget.list(view.attachment_fans.len())?; - for fan in &view.attachment_fans { - budget.string(fan.interaction_id.as_str())?; - budget.string(fan.host.as_str())?; + for fan in view.attachment_fans.values() { budget.list(fan.children.len())?; for child in &fan.children { - budget.string(child.object.as_str())?; - budget.list(child.choice_ids.len())?; - for choice_id in &child.choice_ids { - budget.string(choice_id.as_str())?; - } + budget.string(child.submission.interaction_id.as_str())?; + bound_outbound_response(&child.submission.response, &mut budget)?; } } if let InteractionAvailability::ProgressAvailable { witness } = &view.availability { From 6830a93e86d0e3a6ea24a36e29b451d467b1f7be Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 07:51:32 -0700 Subject: [PATCH 08/10] test(interaction): scope attachment fan picks --- client/src/components/board/__tests__/PermanentCard.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index e5228ae513..7c8d3f6038 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -673,13 +673,14 @@ describe("PermanentCard", () => { useUiStore.setState({ attachmentFanHostId: 1 }); render(); - fireEvent.click(screen.getByLabelText("Test Equipment")); + const fan = document.querySelector("[data-attachment-fan]") as HTMLElement; + fireEvent.click(fan.querySelector('[aria-label="Test Equipment"]') as HTMLElement); expect(dispatchInteraction).toHaveBeenCalledWith({ interactionId: "attachment-interaction", response: { type: "choose", data: { choiceId: "attachment-2" } }, }); - fireEvent.click(screen.getByLabelText("Second Equipment")); + fireEvent.click(fan.querySelector('[aria-label="Second Equipment"]') as HTMLElement); expect(dispatchInteraction).toHaveBeenCalledWith({ interactionId: "attachment-interaction", response: { type: "choose", data: { choiceId: "attachment-4" } }, From 49ff7176139ab5e29c82406b5ae54380bc2fd438 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 08:02:13 -0700 Subject: [PATCH 09/10] fix(interaction): expose attachment ids as wire scalars --- crates/engine/src/game/interaction.rs | 15 ++++++++++----- crates/engine/src/types/interaction.rs | 8 +++----- .../tests/integration/interaction_contract.rs | 6 +++--- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/game/interaction.rs b/crates/engine/src/game/interaction.rs index 6d981af2cb..68a1a2b932 100644 --- a/crates/engine/src/game/interaction.rs +++ b/crates/engine/src/game/interaction.rs @@ -7338,7 +7338,7 @@ fn attachment_fans_for_slot( authoritative_state: &GameState, filtered_state: &GameState, slot: &ActiveInteractionSlot, -) -> BTreeMap { +) -> BTreeMap { let semantic_owner = PlayerId(slot.semantic_owner); let model = human_response_model(&filtered_state.waiting_for, semantic_owner); let object_choices = match model { @@ -7424,7 +7424,7 @@ fn attachment_fans_for_object_choices( interaction_id: &InteractionId, model: HumanResponseModel, object_choices: impl IntoIterator, -) -> BTreeMap { +) -> BTreeMap { let mut fans: BTreeMap< (InteractionId, ObjectId), BTreeMap>, @@ -7463,14 +7463,19 @@ fn attachment_fans_for_object_choices( }; attachment_fan_submission(&interaction_id, model, choice_id.clone()).map( |submission| InteractionAttachmentFanChild { - object_id, + object_id: object_id.0, submission, }, ) }) .collect::>(); - (!children.is_empty()) - .then_some((host_id, InteractionAttachmentFan { host_id, children })) + (!children.is_empty()).then_some(( + host_id.0, + InteractionAttachmentFan { + host_id: host_id.0, + children, + }, + )) }) .collect() } diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 14c831a469..1988ed03cd 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -10,8 +10,6 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use super::identifiers::ObjectId; - pub const MAX_INTERACTION_LIST_LEN: usize = 10_000; macro_rules! opaque_string_id { @@ -1096,7 +1094,7 @@ pub struct InteractionOpportunity { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] pub struct InteractionAttachmentFanChild { - pub object_id: ObjectId, + pub object_id: u64, pub submission: InteractionSubmission, } @@ -1108,7 +1106,7 @@ pub struct InteractionAttachmentFanChild { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] pub struct InteractionAttachmentFan { - pub host_id: ObjectId, + pub host_id: u64, pub children: Vec, } @@ -1145,7 +1143,7 @@ pub struct ViewerInteraction { pub auto_pass_recommended: bool, pub opportunities: Vec, #[serde(default)] - pub attachment_fans: BTreeMap, + pub attachment_fans: BTreeMap, pub availability: InteractionAvailability, } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index d2ae5b1f09..3afc6332b4 100644 --- a/crates/engine/tests/integration/interaction_contract.rs +++ b/crates/engine/tests/integration/interaction_contract.rs @@ -423,11 +423,11 @@ fn attachment_fans_are_per_interaction_filtered_and_direct() { assert_eq!(view.attachment_fans.len(), 1); let fan = view .attachment_fans - .get(&host) + .get(&host.0) .expect("the engine keys the fan by its visible host object"); - assert_eq!(fan.host_id, host); + assert_eq!(fan.host_id, host.0); assert_eq!(fan.children.len(), 1); - assert_eq!(fan.children[0].object_id, attachment); + assert_eq!(fan.children[0].object_id, attachment.0); let submission = fan.children[0].submission.clone(); submit_interaction(runner.state_mut(), P0, submission).expect( "the engine-authored fan submission resolves through production interaction dispatch", From 171f85fc2d1200d2ffcd57e736d1fbc0f4257714 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Wed, 29 Jul 2026 08:25:03 -0700 Subject: [PATCH 10/10] fix(interaction): preserve numeric attachment fan ids --- client/src/adapter/generated/interaction/index.ts | 2 +- crates/engine/src/types/interaction.rs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/client/src/adapter/generated/interaction/index.ts b/client/src/adapter/generated/interaction/index.ts index 0a19db6104..0e71b51b46 100644 --- a/client/src/adapter/generated/interaction/index.ts +++ b/client/src/adapter/generated/interaction/index.ts @@ -107,7 +107,7 @@ export type InteractionAttachmentFan = { hostId: number, children: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: { [key: number]: InteractionAttachmentFan }, availability: InteractionAvailability, }; +export type ViewerInteraction = { waitingForKind: InteractionWaitingForKind, authorizedSubmitters: Array, canSubmit: boolean, autoPassRecommended: boolean, opportunities: Array, attachmentFans: Record, availability: InteractionAvailability, }; export type AmountAssignment = { choiceId: InteractionChoiceId, amount: number, }; diff --git a/crates/engine/src/types/interaction.rs b/crates/engine/src/types/interaction.rs index 1988ed03cd..830f083bb9 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -1094,6 +1094,7 @@ pub struct InteractionOpportunity { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] pub struct InteractionAttachmentFanChild { + #[cfg_attr(feature = "interaction-bindings", ts(type = "number"))] pub object_id: u64, pub submission: InteractionSubmission, } @@ -1106,6 +1107,7 @@ pub struct InteractionAttachmentFanChild { #[serde(rename_all = "camelCase")] #[cfg_attr(feature = "interaction-bindings", ts(rename_all = "camelCase"))] pub struct InteractionAttachmentFan { + #[cfg_attr(feature = "interaction-bindings", ts(type = "number"))] pub host_id: u64, pub children: Vec, } @@ -1143,6 +1145,10 @@ pub struct ViewerInteraction { pub auto_pass_recommended: bool, pub opportunities: Vec, #[serde(default)] + #[cfg_attr( + feature = "interaction-bindings", + ts(type = "Record") + )] pub attachment_fans: BTreeMap, pub availability: InteractionAvailability, }