diff --git a/client/src/adapter/__tests__/server-draft-adapter.test.ts b/client/src/adapter/__tests__/server-draft-adapter.test.ts index d99cc5901d..f3dc81039c 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"]; 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..0e71b51b46 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 = { objectId: number, submission: InteractionSubmission, }; + +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, 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/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..5f1d9cea17 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 ────────────────────────────────────────────────────────── @@ -3233,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", @@ -3518,6 +3523,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..7e6a03d671 100644 --- a/client/src/components/board/AttachmentFan.tsx +++ b/client/src/components/board/AttachmentFan.tsx @@ -3,20 +3,12 @@ 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 { 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 { 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"; @@ -49,19 +41,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 { - isTarget: boolean; - boardEligible: boolean; - isSelected: boolean; - activationActions: GameAction[]; -} - /** * Centered spread of a host permanent plus every permanent attached to it * (Aura / Equipment / Fortification), fanned out at HAND size using the shared @@ -75,50 +54,42 @@ 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. */ 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 showNotification = useAppNotificationStore((s) => s.showNotification); 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 host = hostId != null ? objects?.[hostId] : undefined; + const interactionFan = useMemo( + () => + hostId == null + ? null + : (viewerInteraction?.attachmentFans[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]); - - // 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]); + // 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) => child.objectId) + : host.attachments), + ] + : []; const close = useCallback(() => { setAttachmentFanHost(null); @@ -137,60 +108,20 @@ export function AttachmentFan() { return () => window.removeEventListener("keydown", onKey); }, [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], - ); - const handlePick = useCallback( - (id: ObjectId, choice: CardChoice) => { - if (choice.isTarget) { - dispatchAction({ type: "ChooseTarget", data: { target: { Object: id } } }); - close(); - 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(); - } + (id: ObjectId) => { + const child = interactionFan?.children.find((candidate) => candidate.objectId === id); + if (!child || !viewerInteraction?.canSubmit) return; + 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"), + }); + }); }, - [boardChoice, selectedCardIds, close, toggleSelectedCard, setPendingAbilityChoice], + [close, interactionFan, showNotification, t, 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]); - if (hostId == null || !host || cardIds.length === 0) return null; // Shared compact whole-row fan — sized by the total card count so the host + @@ -220,30 +151,16 @@ export function AttachmentFan() { ))} - {confirmSelection && ( - - )} , document.body, ); @@ -251,20 +168,20 @@ export function AttachmentFan() { 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]); @@ -272,17 +189,9 @@ function FanCard({ const lookup = cardImageLookup(obj); const isToken = obj.display_source === "Token"; - const selectable = choice.isTarget || choice.boardEligible || choice.activationActions.length > 0; - // 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. @@ -295,7 +204,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"}`} @@ -316,12 +225,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 f1009f32a4..ff48188d4d 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, @@ -464,6 +463,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[objectId] ?? null, + [objectId, viewerInteraction], + ); const showAttachmentFan = useCallback(() => { dismissPreview(); @@ -490,31 +495,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/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx index b67c260f0e..7c8d3f6038 100644 --- a/client/src/components/board/__tests__/PermanentCard.test.tsx +++ b/client/src/components/board/__tests__/PermanentCard.test.tsx @@ -2,7 +2,12 @@ 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, + 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 +27,7 @@ import { PermanentCard } from "../PermanentCard.tsx"; vi.mock("../../../game/dispatch.ts", () => ({ dispatchAction: vi.fn(), + dispatchInteraction: vi.fn(), })); vi.mock("../../card/CardImage.tsx", () => ({ @@ -139,6 +145,49 @@ function renderPermanent( ); } +function interactionForAttachedObjects(objectIds: number[]): ViewerInteraction { + const interactionId = "attachment-interaction" as InteractionId; + const choiceId = (objectId: number) => `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: objectIds.map((objectId) => ({ + id: choiceId(objectId), + status: { type: "available" }, + surfaces: [], + })), + }, + }, + surfaces: [], + progress: { selected: 0, minimum: 1, maximum: 1, aggregate: null, confirmable: false }, + }], + attachmentFans: { + 1: { + hostId: 1, + children: objectIds.map((objectId) => ({ + objectId, + submission: { + interactionId, + 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) => ({ @@ -176,6 +225,7 @@ describe("PermanentCard", () => { tapRotation: "classic", }); vi.mocked(dispatchAction).mockClear(); + vi.mocked(dispatchInteraction).mockResolvedValue(); }); afterEach(() => { @@ -521,11 +571,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 +618,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 +637,53 @@ 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" }, + }, + }); + }); + + 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(); + + 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(fan.querySelector('[aria-label="Second Equipment"]') as HTMLElement); + expect(dispatchInteraction).toHaveBeenCalledWith({ + interactionId: "attachment-interaction", + response: { type: "choose", data: { choiceId: "attachment-4" } }, }); }); @@ -606,14 +708,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 +741,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/game/dispatch.ts b/client/src/game/dispatch.ts index 4760e0215d..fc247a4106 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,33 @@ 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( + AdapterErrorCode.UNSUPPORTED, + "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/__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([]); }); 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..083fd60d78 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..7cf60696bc 100644 --- a/crates/engine/src/bin/interaction_bindings.rs +++ b/crates/engine/src/bin/interaction_bindings.rs @@ -2,20 +2,20 @@ 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, - InteractionReasonCode, InteractionRelation, InteractionRelationConstraint, - InteractionRelationSourceConstraint, InteractionResponse, InteractionResponseSpec, - InteractionRoleCode, InteractionShortcutCountSpec, InteractionShortcutDecision, - InteractionShortcutPin, InteractionShortcutPoint, InteractionShortcutPointKind, - InteractionShortcutReply, InteractionShortcutResponseCode, InteractionSlotKind, - InteractionSubmission, InteractionSummaryCode, InteractionWaitingForCode, + InteractionActionCode, InteractionAggregateFunction, InteractionAttachmentFan, + InteractionAttachmentFanChild, InteractionAvailability, InteractionChoice, + InteractionChoiceStatus, InteractionDamageAssignmentMode, InteractionGroupConstraint, + InteractionIntentCode, InteractionManaAbilityActivationScope, InteractionManaColor, + InteractionManaComparator, InteractionManaRestriction, InteractionManaSpecialAction, + InteractionManaSpellCostCriterion, InteractionManaZoneSpendPolarity, InteractionObjectProperty, + 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, }; @@ -41,6 +41,7 @@ fn expected_bindings() -> String { "InteractionChoiceId", "InteractionActionId", "PreviewRequestId", + "InteractionObjectReference", ] { output.push_str(&format!( "export type {name} = string & {{ readonly __brand: \"{name}\" }};\n\n" @@ -97,6 +98,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..68a1a2b932 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,7 +37,8 @@ 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, @@ -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: BTreeMap::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: BTreeMap::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: BTreeMap::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: BTreeMap::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: BTreeMap::new(), availability: InteractionAvailability::Unsupported { reason: InteractionReasonCode::PayloadTooLarge, }, }; } let mut opportunities = Vec::with_capacity(slots.len()); + let mut attachment_fans = BTreeMap::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,193 @@ 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, +) -> BTreeMap { + let semantic_owner = PlayerId(slot.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() + .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() + } + 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, model, object_choices) +} + +fn attachment_fans_for_object_choices( + filtered_state: &GameState, + interaction_id: &InteractionId, + model: HumanResponseModel, + object_choices: impl IntoIterator, +) -> BTreeMap { + 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() + .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: object_id.0, + submission, + }, + ) + }) + .collect::>(); + (!children.is_empty()).then_some(( + host_id.0, + InteractionAttachmentFan { + host_id: host_id.0, + 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, } +/// 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 +7799,14 @@ 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.values() { + budget.list(fan.children.len())?; + for child in &fan.children { + budget.string(child.submission.interaction_id.as_str())?; + bound_outbound_response(&child.submission.response, &mut budget)?; + } + } if let InteractionAvailability::ProgressAvailable { witness } = &view.availability { budget.string(witness.interaction_id.as_str())?; bound_outbound_response(&witness.response, &mut budget)?; @@ -9074,7 +9285,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 +9293,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..830f083bb9 100644 --- a/crates/engine/src/types/interaction.rs +++ b/crates/engine/src/types/interaction.rs @@ -6,6 +6,8 @@ //! 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}; pub const MAX_INTERACTION_LIST_LEN: usize = 10_000; @@ -29,6 +31,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 +1085,33 @@ pub struct InteractionOpportunity { pub progress: InteractionProgress, } +/// 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 { + #[cfg_attr(feature = "interaction-bindings", ts(type = "number"))] + pub object_id: u64, + pub submission: InteractionSubmission, +} + +/// 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 { + #[cfg_attr(feature = "interaction-bindings", ts(type = "number"))] + pub host_id: u64, + pub children: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "interaction-bindings", derive(ts_rs::TS))] #[serde( @@ -1112,6 +1144,12 @@ pub struct ViewerInteraction { pub can_submit: bool, 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, } diff --git a/crates/engine/tests/integration/interaction_contract.rs b/crates/engine/tests/integration/interaction_contract.rs index 2ef04b7dbb..3afc6332b4 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,69 @@ 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); + 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 + .get(&host.0) + .expect("the engine keys the fan by its visible host object"); + assert_eq!(fan.host_id, host.0); + assert_eq!(fan.children.len(), 1); + 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", + ); + assert!( + !runner.state().objects[&attachment].tapped, + "the published attachment submission applies its selected untap" + ); + + 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);