Skip to content
27 changes: 22 additions & 5 deletions client/src/adapter/__tests__/p2p-adapter-multiplayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,17 +581,34 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => {
).toThrow("P2P supports 2-6 players");
});

it("enables multiplayer-mode enforcement on the engine at init time", async () => {
// P2PHostAdapter owns an authoritative WASM engine locally; flipping
// the engine's multiplayer flag during initialize() ensures any stray
// restore_game_state call is refused in the Rust layer.
it("enables multiplayer-mode enforcement at game start, not at lobby open", async () => {
// The engine's multiplayer flag is process-wide and nothing ever clears it,
// so an open host lobby must not set it — it is claimed only when the host
// actually takes the engine, on the line before `initializeGame`, which is
// where `initialize_debug_permissions` reads it.
const { adapter } = makeHost(2);
expect(mockSetMultiplayerMode).not.toHaveBeenCalled();

await adapter.initialize();

expect(mockSetMultiplayerMode).not.toHaveBeenCalled();

await adapter.applySeatMutation({
type: "SetKind",
data: {
seatIndex: 1,
kind: {
type: "Ai",
data: { difficulty: "Medium", deck: { type: "Random" } },
},
},
});
await adapter.initializeGame();

expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1);
expect(mockSetMultiplayerMode).toHaveBeenCalledWith(true);
expect(mockSetMultiplayerMode.mock.invocationCallOrder[0])
.toBeLessThan(mockInitializeGame.mock.invocationCallOrder[0]);
});

it("does not reinitialize the host during the lobby-to-game handoff", async () => {
Expand All @@ -601,7 +618,7 @@ describe("P2PHostAdapter — 3-4p multiplayer", () => {
await adapter.initialize();

expect(mockInitialize).toHaveBeenCalledTimes(1);
expect(mockSetMultiplayerMode).toHaveBeenCalledTimes(1);
expect(mockSetMultiplayerMode).not.toHaveBeenCalled();
});

it("fences a stale host when a same-session resume claims a new incarnation", async () => {
Expand Down
18 changes: 18 additions & 0 deletions client/src/adapter/__tests__/wasm-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const mockWorkerClient = {
exportState: vi.fn().mockResolvedValue("{}"),
restoreState: vi.fn().mockResolvedValue(undefined),
resumeMultiplayerHostState: vi.fn().mockResolvedValue(undefined),
applySeatMutation: vi.fn().mockResolvedValue({ state: {}, delta: {} }),
ping: vi.fn().mockResolvedValue("phase-rs engine ready"),
takeLastPanic: vi.fn().mockResolvedValue(null),
dispose: vi.fn(),
Expand Down Expand Up @@ -647,6 +648,23 @@ describe("WasmAdapter", () => {
});
});

describe("applySeatMutation", () => {
it("does not load the card database", async () => {
await adapter.initialize();

const mutation = JSON.stringify({ type: "AddAiSeat", difficulty: "Medium" });
await adapter.applySeatMutation("{}", mutation);

expect(mockWorkerClient.applySeatMutation).toHaveBeenCalledWith("{}", mutation);
// Seat mutations are a pure reducer over the passed-in seat state plus the
// static starter-deck table; the engine re-resolves against CARD_DB at
// `initializeGame`. Warming it here would put a second full card database
// in memory for every lobby seat change.
expect(mockWorkerClient.loadCardDbFromUrl).not.toHaveBeenCalled();
expect(adapter.cardDbLoaded).toBe(false);
});
});

describe("initializeGame", () => {
it("delegates to worker client with seed", async () => {
await adapter.initialize();
Expand Down
2 changes: 1 addition & 1 deletion client/src/adapter/generated/interaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export type SelectionConstraint = { "type": "count", "data": { min: number, max:

export type ConfirmSemantics = "immediate" | "explicit";

export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "activateManaSource" | "backToManaPayment" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug";
export type InteractionActionCode = "passPriority" | "chooseMeldPair" | "chooseEntryAttackTarget" | "playLand" | "castSpell" | "foretell" | "activateAbility" | "declareAttackers" | "declareBlockers" | "chooseUntap" | "chooseExert" | "chooseEnlist" | "chooseClashOpponent" | "chooseZoneOpponentChooser" | "choosePileOpponent" | "chooseAnnouncingOpponent" | "chooseGiftRecipient" | "chooseAssistPlayer" | "commitAssistPayment" | "mulliganDecision" | "reorderHand" | "tapLandForMana" | "activateManaSource" | "backToManaPayment" | "untapLandForMana" | "spendPoolMana" | "unspendPoolMana" | "selectCards" | "chooseRemoveCounterCostDistribution" | "selectCoinFlips" | "chooseOutsideGameCards" | "selectTargets" | "chooseTarget" | "chooseReplacement" | "chooseEntryController" | "orderTriggers" | "cancelCast" | "equip" | "crewVehicle" | "activateStation" | "saddleMount" | "transform" | "playFaceDown" | "turnFaceUp" | "submitSideboard" | "choosePlayDraw" | "chooseOption" | "submitVoteCandidate" | "submitSpellbookDraft" | "submitPilePartition" | "choosePile" | "chooseBranch" | "submitLifeRedistribution" | "chooseDamageSource" | "selectModes" | "decideOptionalCost" | "chooseAdventureFace" | "chooseModalFace" | "chooseAlternativeCast" | "chooseCastingVariant" | "keepAllCopyTargets" | "choosePermanentTypeSlot" | "activateNinjutsu" | "castSpellAsSneak" | "castSpellAsWebSlinging" | "castSpellForFree" | "castSpellAsMiracle" | "castSpellAsMadness" | "decideOptionalEffect" | "respondToSpliceOffer" | "decideOptionalEffectAndRemember" | "payUnlessCost" | "chooseUnlessCostBranch" | "chooseActivationCostBranch" | "payCombatTax" | "chooseRingBearer" | "choosePair" | "chooseDungeon" | "chooseDungeonRoom" | "unlockRoomDoor" | "rollPlanarDie" | "chooseRoomDoor" | "tapForConvoke" | "harmonizeTap" | "declareCompanion" | "companionToHand" | "discoverChoice" | "graveyardPaidCastChoice" | "cascadeChoice" | "rippleChoice" | "freeCastWindowChoice" | "chooseTopOrBottom" | "chooseMutateMergeSide" | "cipherEncode" | "chooseLegend" | "chooseBattleProtector" | "setAutoPass" | "cancelAutoPass" | "setPhaseStops" | "setPriorityPassingMode" | "setPriorityYield" | "setMayTriggerAutoChoice" | "setTriggerOrderTemplate" | "assignCombatDamage" | "assignBlockerDamage" | "distributeAmong" | "chooseCounterMoveDistribution" | "chooseCountersToRemove" | "submitPayAmount" | "retargetSpell" | "learnDecision" | "selectCategoryPermanents" | "chooseKeptCreatures" | "chooseKeptPermanents" | "chooseX" | "submitPhyrexianChoices" | "chooseManaColor" | "payManaAbilityMana" | "castPreparedCopy" | "chooseSpecializeColor" | "castParadigmCopy" | "passParadigmOffer" | "grantDebugPermission" | "revokeDebugPermission" | "concede" | "declareShortcut" | "respondToShortcut" | "declineShortcut" | "precastCopyShortcut" | "endContinuousEffect" | "debug";

export type InteractionRoleCode = "source" | "candidate" | "partner" | "attackTarget" | "target" | "paymentMode" | "abilityIndex" | "attacker" | "bandCount" | "blocker" | "blocked" | "untap" | "exert" | "enlistTarget" | "enlist" | "opponent" | "assistPlayer" | "assist" | "genericMana" | "mulligan" | "serumPowder" | "handCard" | "selected" | "counterSource" | "counterType" | "amount" | "coinFlipIndex" | "sideboardIndex" | "faceUpExile" | "optionIndex" | "triggerIndex" | "crewMember" | "stationCrew" | "x" | "mainCard" | "sideboardCard" | "playFirst" | "option" | "candidateIndex" | "cardName" | "pileA" | "pile" | "modeIndex" | "pay" | "face" | "castCost" | "permanentType" | "returnCreature" | "permissionSource" | "accept" | "spliceCard" | "splice" | "choice" | "costBranch" | "costBranchIndex" | "pair" | "dungeon" | "roomIndex" | "door" | "operation" | "convokeMana" | "harmonizeCreature" | "harmonize" | "companion" | "castChoice" | "castCard" | "placement" | "mergeSide" | "encodeCreature" | "encode" | "defender" | "protector" | "assignmentMode" | "damageTarget" | "damageAmount" | "trampleDamage" | "controllerDamage" | "destination" | "discardCard" | "learn" | "category" | "kept" | "phyrexianPayment" | "manaChoice" | "count" | "manaPayment" | "producedMana" | "color" | "player" | "castingVariant" | "mode" | "modeCost" | "castingCost" | "voteOption" | "voteCandidate";

Expand Down
15 changes: 10 additions & 5 deletions client/src/adapter/p2p-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1313,18 +1313,17 @@ export class P2PHostAdapter implements EngineAdapter {
}
// Resume path: load the persisted GameState with a fresh RNG seed
// and atomic multiplayer-flag flip. `resumeMultiplayerHostState`
// mirrors server-core's `from_persisted` pattern. Fresh-host path:
// just flip the flag; engine state is populated by the guests
// joining + `initializeGame`.
// mirrors server-core's `from_persisted` pattern. A fresh host lobby
// sets no engine flag at all — `setMultiplayerMode(true)` is deferred to
// `startPregameGameInner`, immediately before `initializeGame` claims
// the engine, so an open lobby leaves zero engine footprint.
if (this.isResume && this.resumeGameState) {
await this.wasm.resumeMultiplayerHostState(this.resumeGameState);
this.resumeGameState = null;
traceAdapter("Host", "initialize-resume", {
tokens: this.playerTokens.size,
gameStarted: this.gameStarted,
});
} else {
await this.wasm.setMultiplayerMode(true);
}
this.resolvePregameReady();
} catch (err) {
Expand Down Expand Up @@ -1645,6 +1644,12 @@ export class P2PHostAdapter implements EngineAdapter {
const playerCount = allowPartialStart
? orderedOpponents.length + 1
: this.pregameSeatState.seats.length;
// Claim the engine for multiplayer here, not at lobby open: the only
// in-creation reader of the flag is `initialize_debug_permissions`,
// evaluated inside `initialize_game`, so setting it on the line before is
// equivalent. The resume path is untouched — `resume_multiplayer_host_state`
// refuses when the flag is already set and sets it itself.
await this.wasm.setMultiplayerMode(true);
const result = await this.wasm.initializeGame(
deckPayload,
this.formatConfig,
Expand Down
2 changes: 2 additions & 0 deletions client/src/adapter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1698,6 +1698,7 @@ export type WaitingFor =
| { type: "DeclareBlockers"; data: { player: PlayerId; valid_blocker_ids: ObjectId[]; valid_block_targets: Record<string, ObjectId[]>; block_requirements?: Record<string, BlockRequirementInfo>; blocker_constraints?: Record<string, CombatRequirement> } }
| { type: "GameOver"; data: { winner: PlayerId | null } }
| { type: "ReplacementChoice"; data: { player: PlayerId; candidate_count: number; candidates?: ReplacementCandidateSummary[] } }
| { type: "EntryControllerChoice"; data: { player: PlayerId; candidates: PlayerId[] } }
| { type: "OrderTriggers"; data: { player: PlayerId; triggers: PendingTriggerSummary[] } }
| { type: "CopyTargetChoice"; data: { player: PlayerId; source_id: ObjectId; valid_targets: ObjectId[]; max_mana_value?: number | null; purpose?: { type: "BecomeCopy" | "PersistChosenAttribute" } } }
| { type: "ExploreChoice"; data: { player: PlayerId; source_id: ObjectId; choosable: ObjectId[]; remaining: ObjectId[]; pending_effect: unknown } }
Expand Down Expand Up @@ -2243,6 +2244,7 @@ export type GameAction =
| { type: "ChooseTarget"; data: { target: TargetRef | null } }
| { type: "ChoosePair"; data: { partner: ObjectId | null } }
| { type: "ChooseReplacement"; data: { index: number } }
| { type: "ChooseEntryController"; data: { opponent: PlayerId } }
| { type: "OrderTriggers"; data: { order: number[] } }
| { type: "CancelCast" }
| { type: "Equip"; data: { equipment_id: ObjectId; target_id: ObjectId } }
Expand Down
14 changes: 12 additions & 2 deletions client/src/adapter/wasm-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,7 +721,9 @@ export class WasmAdapter implements EngineAdapter, AiDecisionDiagnosticsCapabili
* Toggle the engine's multiplayer enforcement flag. When enabled, the
* Rust side refuses `restore_game_state` with a descriptive error —
* defense against any caller trying to rewind a multiplayer game.
* Called by multiplayer adapters (P2P host/guest) after WASM init.
* Called by the P2P host immediately before `initializeGame` — not at
* lobby open. A pregame lobby owns no game state, so enabling enforcement
* there only leaves the flag set on the worker for the lobby's lifetime.
*/
async setMultiplayerMode(enabled: boolean): Promise<void> {
this.assertInitialized();
Expand All @@ -735,7 +737,15 @@ export class WasmAdapter implements EngineAdapter, AiDecisionDiagnosticsCapabili

async applySeatMutation(stateJson: string, mutationJson: string): Promise<unknown> {
this.assertInitialized();
await this.ensureCardDb();
// No `ensureCardDb()` here: `apply_seat_mutation` never reads CARD_DB. Its
// `WasmDeckResolver` resolves only against the static `STARTER_DECKS` table
// (crates/engine/src/starter_decks.rs) and otherwise clones the passed-in
// name list, staying at the name-only layer — `initialize_game` re-resolves
// against CARD_DB when the game actually starts. (The Rust doc comment on
// `apply_seat_mutation` claiming it uses "the TLS card database" is stale;
// read the resolver, not the doc comment.) Warming a ~100 MB DB for every
// lobby seat change is pure cost, and on a host lobby it is a second
// resident copy alongside the shared worker's.
if (this.engine) {
const result = await this.engine.applySeatMutation(stateJson, mutationJson);
this.invalidateAiDecisionDiagnostics();
Expand Down
54 changes: 54 additions & 0 deletions client/src/components/modal/EntryControllerModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";

import type { GameAction, WaitingFor } from "../../adapter/types.ts";
import { useGameDispatch } from "../../hooks/useGameDispatch.ts";
import { useCanActForWaitingState } from "../../hooks/usePlayerId.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { getOpponentDisplayName } from "../../stores/multiplayerStore.ts";
import { ChoiceModal } from "./ChoiceModal.tsx";

type EntryControllerWaitingFor = Extract<
WaitingFor,
{ type: "EntryControllerChoice" }
>;

interface EntryControllerModalContentProps {
waitingFor: EntryControllerWaitingFor;
dispatch: (action: GameAction) => void | Promise<void>;
}

/** CR 614.12a: choose an opponent before the permanent enters the battlefield. */
export function EntryControllerModalContent({
waitingFor,
dispatch,
}: EntryControllerModalContentProps) {
const { t } = useTranslation("game");

return (
<ChoiceModal
title={t("entryController.title")}
subtitle={t("entryController.subtitle")}
options={waitingFor.data.candidates.map((opponent) => ({
id: String(opponent),
label: getOpponentDisplayName(opponent),
}))}
onChoose={(id) => {
dispatch({
type: "ChooseEntryController",
data: { opponent: Number(id) },
});
}}
/>
);
}

export function EntryControllerModal() {
const canActForWaitingState = useCanActForWaitingState();
const dispatch = useGameDispatch();
const waitingFor = useGameStore((s) => s.waitingFor);

if (waitingFor?.type !== "EntryControllerChoice") return null;
if (!canActForWaitingState) return null;

return <EntryControllerModalContent waitingFor={waitingFor} dispatch={dispatch} />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";

import type { GameAction, WaitingFor } from "../../../adapter/types.ts";
import { isWaitingForHandled } from "../../../game/waitingForRegistry.ts";
import { useMultiplayerStore } from "../../../stores/multiplayerStore.ts";
import { EntryControllerModalContent } from "../EntryControllerModal.tsx";

type EntryControllerWaitingFor = Extract<WaitingFor, { type: "EntryControllerChoice" }>;

function entryControllerWaitingFor(): EntryControllerWaitingFor {
return {
type: "EntryControllerChoice",
data: { player: 0, candidates: [2, 1] },
};
}

afterEach(() => {
cleanup();
useMultiplayerStore.setState({ playerNames: new Map() });
});

describe("EntryControllerModalContent", () => {
it("registers the waiting state as handled", () => {
expect(isWaitingForHandled(entryControllerWaitingFor())).toBe(true);
});

it("dispatches the selected entry controller", () => {
useMultiplayerStore.setState({
playerNames: new Map([
[1, "Alice"],
[2, "Bob"],
]),
});
const dispatch = vi.fn<(action: GameAction) => void>();
render(
<EntryControllerModalContent
waitingFor={entryControllerWaitingFor()}
dispatch={dispatch}
/>,
);

expect(screen.getByRole("heading", { name: "Choose Entry Controller" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Bob" }));

expect(dispatch).toHaveBeenCalledWith({
type: "ChooseEntryController",
data: { opponent: 2 },
});
});
});
1 change: 1 addition & 0 deletions client/src/game/waitingForRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const HANDLED_WAITING_FOR_TYPES: ReadonlySet<WaitingFor["type"]> =
"PrecastCopyShortcutOffer",
"RespondToPrecastCopyShortcut",
"ReplacementChoice",
"EntryControllerChoice",
"CopyTargetChoice",
"CopyRetarget",
"ExploreChoice",
Expand Down
4 changes: 4 additions & 0 deletions client/src/i18n/locales/de/game.json
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,10 @@
"title": "Geschenk-Empfänger wählen",
"subtitle": "Wähle, welcher Gegner das versprochene Geschenk erhält."
},
"entryController": {
"title": "Kontrolleur beim Eintreten wählen",
"subtitle": "Wähle, welcher Gegner diese bleibende Karte beim Eintreten kontrolliert."
},
"optionalCost": {
"gift": {
"title": "Ein Geschenk versprechen?",
Expand Down
Loading
Loading