feat(thressgame-coverage): Wave 15 (e2e for 5 parity rules + lift T68/3 parry)
Closes 5 of 5 unit-only parity rules with real Playwright validation: - T83/all_on_red: probabilistic on-turn-start arm seeds BlockAllExceptKing (verified via UI move attempt + restoration) - T83/ice_physics: SlideMustBeMaxDistance forces sliders to max-distance ray step (verified via legal-move highlight + drag rejection) - T68/3 parry (lifted from .fixme): capture triggers RPS → defender wins → cancel-capture restores defender + reverts attacker - T84/religious_conversion: bishop move converts adjacent enemy non-king pieces (verified via data-piece color flip) - T84/kamikaze: capture triggers AOE destroying adjacent non-king; king immune (verified via DOM + RNG seed) Helper: .sisyphus/scripts/run-pw.sh — nohup-based Playwright runner with done-marker poll. Avoids 30min agent timeout when running long e2e suites. Tests: 2865 -> 2866 (+1 unit). E2E: 8/8 pass (was 3 active + 1 fixme; now 8 active + 0 fixme). bun run check exit 0.
This commit is contained in:
parent
17d8afa1f5
commit
4c25277449
14 changed files with 2822 additions and 79 deletions
|
|
@ -63,20 +63,26 @@ import {
|
|||
hasPendingChoice,
|
||||
} from "./choice-timeout.js";
|
||||
import {
|
||||
algebraicToSquare,
|
||||
applyCustomDescriptor,
|
||||
asCustomModifierId,
|
||||
GAME_ENTITY,
|
||||
parseCustomModifierDescriptor,
|
||||
peekPendingChoice,
|
||||
PRESET_STATE_ENTITY,
|
||||
pushPendingChoice,
|
||||
rollbackAttackerFromSnapshot,
|
||||
submitChoiceAndResume,
|
||||
validateProfile,
|
||||
type ActionResult,
|
||||
type ChessAttrKey,
|
||||
type CustomModifierDescriptor,
|
||||
type EffectPrimitiveNode,
|
||||
type ModifierProfile,
|
||||
type ModifierValidationErrorCode,
|
||||
type PendingChoice,
|
||||
type PieceColor,
|
||||
type PieceType,
|
||||
type PlayerAction,
|
||||
} from "@paratype/chess";
|
||||
|
||||
|
|
@ -854,6 +860,44 @@ function handleSubmitChoice(
|
|||
}
|
||||
forgetBroadcastedChoiceId(roomCode, frame.choiceId);
|
||||
|
||||
// T83 — post-resume capture-cleanup. When the resume's
|
||||
// continuation fired `cancel-capture` (set CaptureCancelled =
|
||||
// true and re-inserted defender facts), the dispatcher's stage
|
||||
// 4b is no longer on the call stack to roll back the attacker
|
||||
// and clear the snapshot — the move tick already returned
|
||||
// before the player submitted. Mirror stage 4b's cleanup here:
|
||||
// poll CaptureCancelled, roll back attacker Position +
|
||||
// HasMoved, then retract the flag and the snapshot. Without
|
||||
// this, a parry-style "defender wins" leaves the attacker on
|
||||
// the destination square even though `cancel-capture` "fired"
|
||||
// (only the defender side would be visible to clients).
|
||||
//
|
||||
// The poll is harmless when the continuation didn't fire
|
||||
// cancel-capture (CaptureCancelled is undefined) — the
|
||||
// snapshot retract is still safe (idempotent) so suspended
|
||||
// non-capture flows that happen to share GAME_ENTITY don't
|
||||
// leak state across moves. We only retract the snapshot when
|
||||
// the stack drained, mirroring stage 4b's "clear when
|
||||
// synchronously settled" rule (the resume IS the
|
||||
// suspended-flow's settle step from the player's POV).
|
||||
const engineAfterResume = session.getEngine();
|
||||
const stackAfterResume = (
|
||||
(engineAfterResume.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly unknown[]
|
||||
| undefined) ?? []
|
||||
).length;
|
||||
if (stackAfterResume === 0) {
|
||||
const cancelled =
|
||||
engineAfterResume.session.get(GAME_ENTITY, "CaptureCancelled") === true;
|
||||
if (cancelled) {
|
||||
rollbackAttackerFromSnapshot(engineAfterResume);
|
||||
engineAfterResume.session.retract(GAME_ENTITY, "CaptureCancelled");
|
||||
}
|
||||
if (engineAfterResume.session.contains(GAME_ENTITY, "LastCaptureSnapshot")) {
|
||||
engineAfterResume.session.retract(GAME_ENTITY, "LastCaptureSnapshot");
|
||||
}
|
||||
}
|
||||
|
||||
// Surface the resulting state to clients so any continuation
|
||||
// side-effect (HpBonus writes, fact mutations, terminal-state
|
||||
// detection) becomes visible without waiting for the next
|
||||
|
|
@ -906,6 +950,71 @@ function handleSubmitChoice(
|
|||
*/
|
||||
const TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE = "__test__.activate-descriptor";
|
||||
const TEST_DEBUG_PUSH_CHOICE_TYPE = "__test__.push-pending-choice";
|
||||
/**
|
||||
* T83 — seed an `OnCapturedHooks` entry on a piece (resolved by
|
||||
* algebraic square) so the e2e parry test can drive a real
|
||||
* `on-captured` trigger via the engine's `applyMove` capture path
|
||||
* without going through `applyCustomDescriptor` (which fires the
|
||||
* inner `request-choice` eagerly at apply time, before the capture
|
||||
* event arrives — incompatible with the capture-suspension contract).
|
||||
*
|
||||
* Mirrors the unit-test seeding strategy in
|
||||
* `packages/chess/src/__fixtures__/parity/parry-real.test.ts` and
|
||||
* `packages/server/src/ws.cancel-capture-revert.test.ts`: the
|
||||
* descriptor's outer `on-captured` is ignored; we read its inner
|
||||
* arm and write it directly onto the piece's `OnCapturedHooks`
|
||||
* fact, with the descriptor's real id threaded through so
|
||||
* `submitChoiceAndResume` can resolve the descriptor at resume
|
||||
* time (Wave 14 / Gap G).
|
||||
*/
|
||||
const TEST_DEBUG_SEED_ON_CAPTURED_HOOK_TYPE = "__test__.seed-on-captured-hook";
|
||||
/**
|
||||
* T84 — broader test-only board-setup frame. Used by parity-religious /
|
||||
* parity-rules e2e specs to:
|
||||
* 1. Wipe the FIDE starting position (optionally preserving kings)
|
||||
* so the test has full control over which pieces sit where.
|
||||
* 2. Insert a deterministic placement set (`{ square, type, color }`)
|
||||
* and return their assigned EntityIds keyed by handle.
|
||||
* 3. Optionally pin `RngSeed` for descriptors with `with-probability`.
|
||||
* 4. Optionally seed per-piece trigger hooks
|
||||
* (OnMoveHooks / OnCaptureHooks / OnTurnEndHooks / …) with a
|
||||
* descriptor's INNER arm — the same shape that on-move.apply() /
|
||||
* on-capture.apply() would seed via the canonical seeder
|
||||
* primitives. Mirrors the hand-seeding used in
|
||||
* religious_conversion-real.test.ts and kamikaze-real.test.ts.
|
||||
*
|
||||
* Distinct from `__test__.seed-on-captured-hook` (T83): that handler
|
||||
* is hard-coded to the on-captured primitive + writes to
|
||||
* OnCapturedHooks. This one is generic across the 10 per-piece hook
|
||||
* attrs and combines clear-board + place-pieces + seed-hooks +
|
||||
* RngSeed in one round-trip — the e2e doesn't have to interleave
|
||||
* three frames + three replies.
|
||||
*
|
||||
* Gated to NODE_ENV !== "production" alongside the other __test__.*
|
||||
* frames.
|
||||
*/
|
||||
const TEST_DEBUG_SETUP_BOARD_TYPE = "__test__.setup-board";
|
||||
/**
|
||||
* T83 (Wave 15) — apply a custom descriptor in-place via
|
||||
* `applyCustomDescriptor`. Drives parity rules whose root is NOT
|
||||
* `on-rule-activated` wrapping a request-choice (e.g. `all_on_red`
|
||||
* starts with `on-turn-start`; `ice_physics` starts with
|
||||
* `on-rule-activated` wrapping `for-each-piece`). Distinct from
|
||||
* `__test__.activate-descriptor` (T79) which only handles
|
||||
* `on-rule-activated → request-choice` and synthesizes a
|
||||
* PendingChoice frame; this one runs the full descriptor walker
|
||||
* AND fires `on-rule-activated` hooks so a descriptor's
|
||||
* activation cascade (seed `BlockAllExceptKing`,
|
||||
* `SlideMustBeMaxDistance` on every slider, …) lands on the
|
||||
* engine before the e2e's first move.
|
||||
*
|
||||
* Apply target is `GAME_ENTITY` (chosen for its symmetry with
|
||||
* `applyCustomDescriptor`'s use in the integration preset). The
|
||||
* walker writes per-piece hook seeds via `for-each-piece` arms;
|
||||
* GAME_ENTITY is the canonical "no-particular-piece" target the
|
||||
* other parity tests use (see ice_physics-real.test.ts).
|
||||
*/
|
||||
const TEST_DEBUG_APPLY_DESCRIPTOR_TYPE = "__test__.apply-descriptor";
|
||||
const TEST_DEBUG_ENABLED = process.env["NODE_ENV"] !== "production";
|
||||
|
||||
interface TestActivateDescriptorPayload {
|
||||
|
|
@ -930,7 +1039,11 @@ function isTestDebugFrame(parsed: unknown): parsed is { type: string; payload: u
|
|||
const t = (parsed as { type: unknown }).type;
|
||||
return (
|
||||
typeof t === "string" &&
|
||||
(t === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE || t === TEST_DEBUG_PUSH_CHOICE_TYPE)
|
||||
(t === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE ||
|
||||
t === TEST_DEBUG_PUSH_CHOICE_TYPE ||
|
||||
t === TEST_DEBUG_SEED_ON_CAPTURED_HOOK_TYPE ||
|
||||
t === TEST_DEBUG_SETUP_BOARD_TYPE ||
|
||||
t === TEST_DEBUG_APPLY_DESCRIPTOR_TYPE)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1108,6 +1221,753 @@ function handleTestActivateDescriptor(
|
|||
void GAME_ENTITY; // silence unused import in case the lints get strict
|
||||
}
|
||||
|
||||
/**
|
||||
* T83 — payload shape for `__test__.seed-on-captured-hook`.
|
||||
*
|
||||
* `square`: 0..63 LERF index of the piece to receive the hook.
|
||||
* The handler walks `session.allFacts()` to resolve the
|
||||
* piece-id whose `Position` matches; if no piece is at the
|
||||
* square the request returns INVALID_MESSAGE (non-fatal).
|
||||
*
|
||||
* `descriptor`: a custom-modifier descriptor whose top-level
|
||||
* primitive MUST be `on-captured` — the handler reads the
|
||||
* inner arm (`params.primitives`) and writes it onto the
|
||||
* target piece's `OnCapturedHooks` fact.
|
||||
*
|
||||
* `roomCode`: same load-bearing reason as in
|
||||
* `__test__.activate-descriptor` — the test frame travels on
|
||||
* the existing authenticated client socket but the handler
|
||||
* doesn't trust ws.data.roomCode (which the e2e doesn't
|
||||
* populate via the multiplayer view's auth path).
|
||||
*/
|
||||
interface TestSeedOnCapturedHookPayload {
|
||||
descriptor: unknown;
|
||||
square: number;
|
||||
roomCode: string;
|
||||
}
|
||||
|
||||
function handleTestSeedOnCapturedHook(
|
||||
ws: ServerWebSocket<ClientData>,
|
||||
payload: unknown,
|
||||
): void {
|
||||
if (!TEST_DEBUG_ENABLED) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
"test-debug frames disabled in production",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedPayload: TestSeedOnCapturedHookPayload;
|
||||
try {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
throw new Error("payload must be an object");
|
||||
}
|
||||
parsedPayload = payload as TestSeedOnCapturedHookPayload;
|
||||
if (typeof parsedPayload.roomCode !== "string") {
|
||||
throw new Error("roomCode must be a string");
|
||||
}
|
||||
if (
|
||||
typeof parsedPayload.square !== "number" ||
|
||||
!Number.isInteger(parsedPayload.square) ||
|
||||
parsedPayload.square < 0 ||
|
||||
parsedPayload.square > 63
|
||||
) {
|
||||
throw new Error("square must be an integer in [0, 63]");
|
||||
}
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.seed-on-captured-hook: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionRegistry.get(parsedPayload.roomCode);
|
||||
if (!session) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.seed-on-captured-hook: no session for room ${parsedPayload.roomCode}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let descriptor: CustomModifierDescriptor;
|
||||
try {
|
||||
descriptor = parseCustomModifierDescriptor(parsedPayload.descriptor);
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.seed-on-captured-hook: descriptor parse failed: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const onCapturedNode = descriptor.primitives[0];
|
||||
if (onCapturedNode === undefined || onCapturedNode.kind !== "on-captured") {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
"__test__.seed-on-captured-hook: descriptor.primitives[0].kind must be 'on-captured'",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const innerArm = (onCapturedNode.params as {
|
||||
primitives: EffectPrimitiveNode[];
|
||||
}).primitives;
|
||||
|
||||
const engine = session.getEngine();
|
||||
|
||||
// T83 — register a LIFTED descriptor whose `primitives` ARE the
|
||||
// inner arm (request-choice + then-continuation). This mirrors
|
||||
// the activate-descriptor handler's `liftOnRuleActivatedArm`
|
||||
// strategy and is load-bearing for the resume path:
|
||||
//
|
||||
// `submitChoiceAndResume` walks `descriptor.primitives` via
|
||||
// `walkTriggerPath(triggerPath)`. The on-captured-fired
|
||||
// PendingChoice frame carries `triggerPath: []` and
|
||||
// `primitiveIndex: 0` (the request-choice's position inside
|
||||
// the inner arm — that's what `runPrimitives` sees when the
|
||||
// dispatcher invokes the hook with the inner arm as its node
|
||||
// list). With the unlifted descriptor, `walkTriggerPath` would
|
||||
// return `[on-captured]` and `arm[0]` would resolve to the
|
||||
// wrapper node, NOT the request-choice — so the resume's
|
||||
// `params.then` continuation walk would fail to find
|
||||
// `cancel-capture`.
|
||||
//
|
||||
// The lifted descriptor's id is the original id with a stable
|
||||
// suffix so re-seeding the same descriptor on a different
|
||||
// square doesn't collide. The HOOK entry stores the LIFTED id
|
||||
// (the one the dispatcher threads into the PendingChoice
|
||||
// frame), so resume looks up the lifted descriptor and finds
|
||||
// the request-choice at primitives[0] as expected.
|
||||
const liftedId = `${String(descriptor.id)}__lifted-on-captured`;
|
||||
const liftedDescriptor: CustomModifierDescriptor = {
|
||||
...descriptor,
|
||||
id: asCustomModifierId(liftedId),
|
||||
primitives: innerArm,
|
||||
};
|
||||
engine.customModifiers.register(liftedDescriptor);
|
||||
|
||||
// Locate the piece at the given square. `pieceId` filtering
|
||||
// mirrors the convention used in ws.cancel-capture-revert.test.ts:
|
||||
// a Position fact with id > 0 is a piece (game-level entities
|
||||
// use negative ids).
|
||||
let targetId: number | null = null;
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (
|
||||
f.attr === "Position" &&
|
||||
f.value === parsedPayload.square &&
|
||||
(f.id as number) > 0
|
||||
) {
|
||||
targetId = f.id as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (targetId === null) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.seed-on-captured-hook: no piece at square ${String(parsedPayload.square)}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cast through the session.insert binding's branded EntityId.
|
||||
const targetEntity = targetId as unknown as Parameters<
|
||||
typeof engine.session.insert
|
||||
>[0];
|
||||
|
||||
const existing =
|
||||
(engine.session.get(targetEntity, "OnCapturedHooks") as
|
||||
| ReadonlyArray<{
|
||||
descriptorId: string;
|
||||
target: unknown;
|
||||
primitives: EffectPrimitiveNode[];
|
||||
}>
|
||||
| undefined) ?? [];
|
||||
engine.session.insert(targetEntity, "OnCapturedHooks", [
|
||||
...existing,
|
||||
{
|
||||
descriptorId: liftedId,
|
||||
target: "self",
|
||||
primitives: innerArm,
|
||||
},
|
||||
]);
|
||||
|
||||
// T83: ensure the modifier-integration preset is active so the
|
||||
// engine's `onAfterMove` dispatcher actually runs on the next
|
||||
// applyMove tick. Without it, fireOnCapturedHooks never fires
|
||||
// (the integration preset is what wires the trigger pipeline
|
||||
// into the engine's move tick — see engine.ts § auto-activate
|
||||
// when a profile is present). Default e2e rooms init the
|
||||
// engine WITHOUT a profile (no `room.create` profile arg), so
|
||||
// the preset isn't active by default. Idempotent: skipping the
|
||||
// prepend when already present.
|
||||
const presetList = engine.activePresets.list();
|
||||
if (!presetList.some((p) => p.id === "__modifier-profile-integration__")) {
|
||||
engine.activePresets.replaceAll([
|
||||
{
|
||||
id: "__modifier-profile-integration__",
|
||||
scope: "both" as const,
|
||||
turnsRemaining: null,
|
||||
},
|
||||
...presetList.map((p) => ({
|
||||
id: p.id,
|
||||
scope: p.scope,
|
||||
turnsRemaining: p.turnsRemaining,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
// T79: same choice-timeout relaxation as activate-descriptor —
|
||||
// the e2e churns multiple sockets during setup and the default
|
||||
// timeout-with-default policy auto-forfeits on transient
|
||||
// disconnects. Set no-timeout so the suspended choice survives
|
||||
// the test's timing variance.
|
||||
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
|
||||
mode: "no-timeout",
|
||||
});
|
||||
|
||||
// T83: broadcast a fresh game.state so the e2e can `await` the
|
||||
// round-trip via the client's snapshot listener (or simply via
|
||||
// a brief settle delay) — without this, the seed insert is
|
||||
// server-only and there's no observable signal the seed
|
||||
// committed before the next inbound `game.move`.
|
||||
broadcastGameStateSnapshot(parsedPayload.roomCode, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* T83 (Wave 15) — payload + handler for `__test__.apply-descriptor`.
|
||||
*
|
||||
* Parses the supplied descriptor, registers it on the engine's
|
||||
* customModifiers registry (so subsequent trigger fires can resolve
|
||||
* the id), and runs `applyCustomDescriptor(engine, session,
|
||||
* GAME_ENTITY, descriptor)`. The walker:
|
||||
* - Walks every primitive in `descriptor.primitives` once,
|
||||
* applying each via the registered primitive's `apply()`. For
|
||||
* trigger primitives (`on-turn-start`, `on-rule-activated`,
|
||||
* `for-each-piece`, …) `apply()` either seeds a hook fact (the
|
||||
* pre-iteration triggers) or runs the inner cascade in-place
|
||||
* (the `on-rule-activated` family — see
|
||||
* `on-rule-activated.test.ts` § "Integration-level" for the
|
||||
* fire-once contract).
|
||||
* - Fires `on-rule-activated` hooks for any `on-rule-activated`
|
||||
* blocks discovered in the tree, ONCE per descriptor instance.
|
||||
*
|
||||
* For descriptors like `all_on_red` (root = `on-turn-start`) the
|
||||
* walker simply seeds `OnTurnStartHooks` on `GAME_ENTITY` — the
|
||||
* trigger fires on subsequent applyMove() calls. For descriptors
|
||||
* like `ice_physics` (root = `on-rule-activated → for-each-piece`)
|
||||
* the walker runs the cascade in-place, setting
|
||||
* `SlideMustBeMaxDistance = true` on every slider.
|
||||
*
|
||||
* After apply, broadcasts a fresh `game.state` snapshot so the
|
||||
* client mirrors the post-apply engine facts.
|
||||
*
|
||||
* Optional `rngSeed` pins the engine's RNG — load-bearing for
|
||||
* `with-probability` descriptors (`all_on_red`) where the e2e
|
||||
* needs deterministic hits.
|
||||
*/
|
||||
interface TestApplyDescriptorPayload {
|
||||
descriptor: unknown;
|
||||
roomCode: string;
|
||||
rngSeed?: number;
|
||||
/**
|
||||
* T83: optional square (0..63 LERF) to apply the descriptor to.
|
||||
* Resolves to the piece-id at that square. Used by descriptors
|
||||
* whose root trigger fires per-piece (e.g. `on-turn-start`,
|
||||
* `on-move`) — the dispatcher's `fire*Hooks` iterators walk
|
||||
* pieces, so a hook seeded on `GAME_ENTITY` would never fire.
|
||||
* Defaults to `GAME_ENTITY` (-1) for descriptors whose root is
|
||||
* `on-rule-activated` (one-shot game-level cascade — e.g.
|
||||
* `ice_physics`).
|
||||
*/
|
||||
targetSquare?: number;
|
||||
}
|
||||
|
||||
function handleTestApplyDescriptor(
|
||||
ws: ServerWebSocket<ClientData>,
|
||||
payload: unknown,
|
||||
): void {
|
||||
if (!TEST_DEBUG_ENABLED) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
"test-debug frames disabled in production",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsedPayload: TestApplyDescriptorPayload;
|
||||
try {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
throw new Error("payload must be an object");
|
||||
}
|
||||
parsedPayload = payload as TestApplyDescriptorPayload;
|
||||
if (typeof parsedPayload.roomCode !== "string") {
|
||||
throw new Error("roomCode must be a string");
|
||||
}
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.apply-descriptor: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionRegistry.get(parsedPayload.roomCode);
|
||||
if (!session) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.apply-descriptor: no session for room ${parsedPayload.roomCode}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let descriptor: CustomModifierDescriptor;
|
||||
try {
|
||||
descriptor = parseCustomModifierDescriptor(parsedPayload.descriptor);
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.apply-descriptor: descriptor parse failed: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const engine = session.getEngine();
|
||||
|
||||
if (typeof parsedPayload.rngSeed === "number") {
|
||||
engine.setRngSeed(parsedPayload.rngSeed);
|
||||
}
|
||||
|
||||
// Register so trigger-fired choices (if any) can resolve the
|
||||
// descriptor by id. Idempotent — re-applying the same descriptor
|
||||
// re-runs the walker but `RuleActivatedFiredFor` guards against
|
||||
// double-firing the activated cascade.
|
||||
engine.customModifiers.register(descriptor);
|
||||
|
||||
// T83: ensure the modifier-integration preset is active. Same
|
||||
// reasoning as in `handleTestSeedOnCapturedHook`: the trigger
|
||||
// pipeline (fireOnTurnStartHooks, fireOnCapturedHooks, etc.)
|
||||
// only runs when this preset is in the active set. Idempotent.
|
||||
const presetList = engine.activePresets.list();
|
||||
if (!presetList.some((p) => p.id === "__modifier-profile-integration__")) {
|
||||
engine.activePresets.replaceAll([
|
||||
{
|
||||
id: "__modifier-profile-integration__",
|
||||
scope: "both" as const,
|
||||
turnsRemaining: null,
|
||||
},
|
||||
...presetList.map((p) => ({
|
||||
id: p.id,
|
||||
scope: p.scope,
|
||||
turnsRemaining: p.turnsRemaining,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
// Resolve the apply target: a piece-id at `targetSquare` if
|
||||
// supplied, otherwise GAME_ENTITY. Per-piece hook descriptors
|
||||
// (`on-turn-start`, `on-move`, …) need a real piece-id because
|
||||
// the dispatchers iterate pieces only.
|
||||
let applyTarget: typeof GAME_ENTITY = GAME_ENTITY;
|
||||
if (typeof parsedPayload.targetSquare === "number") {
|
||||
let resolved: number | null = null;
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (
|
||||
f.attr === "Position" &&
|
||||
f.value === parsedPayload.targetSquare &&
|
||||
(f.id as number) > 0
|
||||
) {
|
||||
resolved = f.id as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (resolved === null) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.apply-descriptor: no piece at targetSquare ${String(parsedPayload.targetSquare)}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
applyTarget = resolved as unknown as typeof GAME_ENTITY;
|
||||
}
|
||||
|
||||
try {
|
||||
applyCustomDescriptor(engine, engine.session, applyTarget, descriptor);
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.apply-descriptor: apply threw: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// T79: same choice-timeout relaxation reasoning as the other
|
||||
// test-debug handlers — the e2e churns sockets during setup; the
|
||||
// default timeout-with-default policy auto-forfeits the room on a
|
||||
// transient disconnect, which makes multi-second waits flaky.
|
||||
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
|
||||
mode: "no-timeout",
|
||||
});
|
||||
|
||||
// Broadcast fresh game.state so the client renders the post-apply
|
||||
// facts (e.g. `SlideMustBeMaxDistance = true` on every slider).
|
||||
broadcastGameStateSnapshot(parsedPayload.roomCode, session);
|
||||
}
|
||||
|
||||
// T84 — board setup payload + helpers ---------------------------------------
|
||||
|
||||
interface TestSetupBoardPlacement {
|
||||
/** Algebraic square ("e4") OR numeric square index (0..63). */
|
||||
square: string | number;
|
||||
type: PieceType;
|
||||
color: PieceColor;
|
||||
/** Optional: stamp `HasMoved` on placement (default false). */
|
||||
hasMoved?: boolean;
|
||||
/**
|
||||
* Optional handle so the e2e can later refer to "the bishop we
|
||||
* placed" by name. Returned in the `__test__.board-ready` reply
|
||||
* keyed by handle for piece-id-based assertions.
|
||||
*/
|
||||
handle?: string;
|
||||
}
|
||||
|
||||
interface TestSeedHookSpec {
|
||||
/** Handle (from a placement) OR algebraic / numeric square. */
|
||||
pieceHandle?: string;
|
||||
pieceSquare?: string | number;
|
||||
/**
|
||||
* Hook attribute on the schema. Caller is responsible for matching
|
||||
* the descriptor's root primitive (e.g. `on-move` → OnMoveHooks,
|
||||
* `on-capture` → OnCaptureHooks).
|
||||
*/
|
||||
hookAttr:
|
||||
| "OnMoveHooks"
|
||||
| "OnCaptureHooks"
|
||||
| "OnCapturedHooks"
|
||||
| "OnDamagedHooks"
|
||||
| "OnPromotionHooks"
|
||||
| "OnTurnStartHooks"
|
||||
| "OnTurnEndHooks"
|
||||
| "OnCheckReceivedHooks"
|
||||
| "OnCheckDeliveredHooks"
|
||||
| "OnMovedOntoSquareHooks";
|
||||
/** Descriptor whose root primitive's `params.primitives` is the inner arm. */
|
||||
descriptor: unknown;
|
||||
/** Override the descriptor id used in the registry / hook entry. */
|
||||
descriptorIdOverride?: string;
|
||||
}
|
||||
|
||||
interface TestSetupBoardPayload {
|
||||
roomCode: string;
|
||||
/** Wipe FIDE starting position before placing. Default: true. */
|
||||
clear?: boolean;
|
||||
/** When clearing, retract kings too. Default: false (preserve kings). */
|
||||
clearIncludingKings?: boolean;
|
||||
placements?: TestSetupBoardPlacement[];
|
||||
rngSeed?: number;
|
||||
hooks?: TestSeedHookSpec[];
|
||||
/** Override `Turn`. Default: leave unchanged. */
|
||||
turn?: PieceColor;
|
||||
}
|
||||
|
||||
function squareIndex(sq: string | number): number {
|
||||
return typeof sq === "string" ? algebraicToSquare(sq) : sq;
|
||||
}
|
||||
|
||||
function findPieceIdAt(
|
||||
engine: ReturnType<GameSession["getEngine"]>,
|
||||
sq: number,
|
||||
): number | null {
|
||||
for (const f of engine.session.allFacts()) {
|
||||
if (
|
||||
f.attr === "Position" &&
|
||||
f.value === sq &&
|
||||
(f.id as number) > 0
|
||||
) {
|
||||
return f.id as number;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearBoardOnEngine(
|
||||
engine: ReturnType<GameSession["getEngine"]>,
|
||||
preserveKings: boolean,
|
||||
): void {
|
||||
const facts = engine.session.allFacts();
|
||||
const toClear: number[] = [];
|
||||
for (const f of facts) {
|
||||
if (f.attr !== "PieceType") continue;
|
||||
if ((f.id as number) <= 0) continue;
|
||||
if (preserveKings && f.value === "king") continue;
|
||||
toClear.push(f.id as number);
|
||||
}
|
||||
for (const id of toClear) {
|
||||
for (const attr of engine.effectivePieceAttrs as readonly ChessAttrKey[]) {
|
||||
if (engine.session.contains(id as never, attr)) {
|
||||
engine.session.retract(id as never, attr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function placePieceOnEngine(
|
||||
engine: ReturnType<GameSession["getEngine"]>,
|
||||
type: PieceType,
|
||||
color: PieceColor,
|
||||
sq: number,
|
||||
hasMoved: boolean,
|
||||
): number {
|
||||
const id = engine.session.nextId();
|
||||
engine.session.insert(id, "PieceType", type);
|
||||
engine.session.insert(id, "Color", color);
|
||||
engine.session.insert(id, "Position", sq);
|
||||
engine.session.insert(id, "HasMoved", hasMoved);
|
||||
return id as number;
|
||||
}
|
||||
|
||||
function handleTestSetupBoard(
|
||||
ws: ServerWebSocket<ClientData>,
|
||||
payload: unknown,
|
||||
): void {
|
||||
if (!TEST_DEBUG_ENABLED) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
"test-debug frames disabled in production",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: TestSetupBoardPayload;
|
||||
try {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
throw new Error("payload must be an object");
|
||||
}
|
||||
parsed = payload as TestSetupBoardPayload;
|
||||
if (typeof parsed.roomCode !== "string") {
|
||||
throw new Error("roomCode must be a string");
|
||||
}
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.setup-board: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const session = sessionRegistry.get(parsed.roomCode);
|
||||
if (!session) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.setup-board: no session for room ${parsed.roomCode}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const engine = session.getEngine();
|
||||
|
||||
// 0. Ensure the integration preset is active. The server's
|
||||
// GameSession only auto-activates `__modifier-profile-integration__`
|
||||
// when `room.create` carries a profile (see game-session.ts §
|
||||
// constructor + engine.ts § auto-activate). Without it,
|
||||
// `engine.applyMove` runs the move but the onAfterMove dispatcher
|
||||
// NEVER calls `fireOn{Move,Capture,...}Hooks` — meaning every
|
||||
// seeded trigger hook is a silent no-op. The default e2e room
|
||||
// creation path doesn't include a profile, so we explicitly
|
||||
// activate the integration preset here. Idempotent: if it's
|
||||
// already in the activation list (a custom-profile room would
|
||||
// have it), this is a no-op.
|
||||
const presets = engine.activePresets.list();
|
||||
if (
|
||||
!presets.some((p) => p.id === "__modifier-profile-integration__")
|
||||
) {
|
||||
engine.activePresets.replaceAll([
|
||||
{
|
||||
id: "__modifier-profile-integration__",
|
||||
scope: "both",
|
||||
turnsRemaining: null,
|
||||
},
|
||||
...presets.map((p) => ({
|
||||
id: p.id,
|
||||
scope: p.scope,
|
||||
turnsRemaining: p.turnsRemaining,
|
||||
})),
|
||||
]);
|
||||
}
|
||||
|
||||
// 1. Clear board (default: yes, preserve kings).
|
||||
if (parsed.clear !== false) {
|
||||
clearBoardOnEngine(engine, !(parsed.clearIncludingKings === true));
|
||||
}
|
||||
|
||||
// 2. Place pieces. Track handle → entityId for handle-based hook seeds.
|
||||
const handleToId = new Map<string, number>();
|
||||
const placements = parsed.placements ?? [];
|
||||
for (const p of placements) {
|
||||
const sq = squareIndex(p.square);
|
||||
const id = placePieceOnEngine(
|
||||
engine,
|
||||
p.type,
|
||||
p.color,
|
||||
sq,
|
||||
p.hasMoved ?? false,
|
||||
);
|
||||
if (p.handle !== undefined) handleToId.set(p.handle, id);
|
||||
}
|
||||
|
||||
// 3. Optional RngSeed pin.
|
||||
if (typeof parsed.rngSeed === "number") {
|
||||
engine.setRngSeed(parsed.rngSeed);
|
||||
}
|
||||
|
||||
// 4. Optional Turn override.
|
||||
if (parsed.turn === "white" || parsed.turn === "black") {
|
||||
engine.session.insert(GAME_ENTITY, "Turn", parsed.turn);
|
||||
}
|
||||
|
||||
// 5. Seed per-piece trigger hooks. Caller passes the descriptor +
|
||||
// target piece + hook attr; we read the descriptor's root
|
||||
// primitive's `params.primitives` (the inner arm) and write the
|
||||
// {descriptorId, primitives} entry under the requested attr.
|
||||
// This mirrors what on-move.apply() / on-capture.apply() would
|
||||
// produce via the canonical seeder primitives, without driving
|
||||
// `applyCustomDescriptor` (which has unrelated walker concerns
|
||||
// documented in the parity *-real.test.ts files).
|
||||
const hooks = parsed.hooks ?? [];
|
||||
for (const spec of hooks) {
|
||||
let descriptor: CustomModifierDescriptor;
|
||||
try {
|
||||
descriptor = parseCustomModifierDescriptor(spec.descriptor);
|
||||
} catch (err) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.setup-board: descriptor parse failed: ${(err as Error).message}`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let pieceId: number | null = null;
|
||||
if (spec.pieceHandle !== undefined) {
|
||||
pieceId = handleToId.get(spec.pieceHandle) ?? null;
|
||||
} else if (spec.pieceSquare !== undefined) {
|
||||
pieceId = findPieceIdAt(engine, squareIndex(spec.pieceSquare));
|
||||
}
|
||||
if (pieceId === null) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
`__test__.setup-board: hook target not found (handle=${String(spec.pieceHandle)} square=${String(spec.pieceSquare)})`,
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const idForRegistry =
|
||||
spec.descriptorIdOverride !== undefined
|
||||
? asCustomModifierId(spec.descriptorIdOverride)
|
||||
: descriptor.id;
|
||||
if (!engine.customModifiers.has(idForRegistry)) {
|
||||
engine.customModifiers.register({ ...descriptor, id: idForRegistry });
|
||||
}
|
||||
const root = descriptor.primitives[0];
|
||||
if (root === undefined) {
|
||||
sendTo(
|
||||
ws,
|
||||
errorMessage(
|
||||
"INVALID_MESSAGE",
|
||||
"__test__.setup-board: descriptor has no root primitive",
|
||||
false,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const innerArm = (root.params as { primitives?: EffectPrimitiveNode[] })
|
||||
.primitives ?? [];
|
||||
engine.session.insert(
|
||||
pieceId as never,
|
||||
spec.hookAttr as ChessAttrKey,
|
||||
[
|
||||
{ descriptorId: idForRegistry, primitives: innerArm },
|
||||
] as unknown as never,
|
||||
);
|
||||
}
|
||||
|
||||
// 6. Same choice-timeout relaxation as the other test-debug handlers
|
||||
// (T79 rationale): churned sockets during /game mount otherwise
|
||||
// auto-forfeit the room mid-test.
|
||||
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
|
||||
mode: "no-timeout",
|
||||
});
|
||||
|
||||
// 7. Broadcast fresh game.state so connected clients render the
|
||||
// synthetic board before the test drives any move.
|
||||
broadcastGameStateSnapshot(parsed.roomCode, session);
|
||||
}
|
||||
|
||||
let testChoiceCounter = 0;
|
||||
|
||||
/**
|
||||
|
|
@ -1139,6 +1999,12 @@ export function handleMessage(
|
|||
const { type, payload } = earlyParsed;
|
||||
if (type === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE) {
|
||||
handleTestActivateDescriptor(ws, payload);
|
||||
} else if (type === TEST_DEBUG_SEED_ON_CAPTURED_HOOK_TYPE) {
|
||||
handleTestSeedOnCapturedHook(ws, payload);
|
||||
} else if (type === TEST_DEBUG_SETUP_BOARD_TYPE) {
|
||||
handleTestSetupBoard(ws, payload);
|
||||
} else if (type === TEST_DEBUG_APPLY_DESCRIPTOR_TYPE) {
|
||||
handleTestApplyDescriptor(ws, payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import type { ServerWebSocket } from "bun";
|
|||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
asCustomModifierId,
|
||||
GAME_ENTITY,
|
||||
parseCustomModifierDescriptor,
|
||||
type EffectPrimitiveNode,
|
||||
|
|
@ -296,6 +297,32 @@ function armCancelCaptureOnPiece(ctx: RoomCtx, defenderId: number): void {
|
|||
primitives: EffectPrimitiveNode[];
|
||||
}).primitives;
|
||||
|
||||
// T83 — also register a LIFTED descriptor whose top-level
|
||||
// primitives ARE the inner arm. This is load-bearing for the
|
||||
// resume path: `submitChoiceAndResume` walks the descriptor's
|
||||
// `primitives` via `walkTriggerPath(triggerPath)`. The
|
||||
// on-captured-fired PendingChoice frame carries `triggerPath:
|
||||
// []` and `primitiveIndex: 0` (the request-choice's position
|
||||
// inside the inner arm — that's what runPrimitives sees when
|
||||
// the dispatcher invokes the hook with the inner arm as its
|
||||
// node list). With the unlifted descriptor, walkTriggerPath
|
||||
// would return `[on-captured]` and arm[0] would resolve to the
|
||||
// wrapper node, NOT the request-choice — so the resume's
|
||||
// `params.then` continuation walk would fail to find
|
||||
// cancel-capture. Pre-T83 the broadcast-revert assertion below
|
||||
// (T81's "post-resume game.state") fired BEFORE this resume
|
||||
// discrepancy mattered (the test only asserted that the
|
||||
// snapshot was broadcast, not that the engine state inside it
|
||||
// reflected cancel-capture). The T83 follow-on assertion
|
||||
// tightens that — see the new "T83: defender-wins parry submit
|
||||
// fully restores defender + attacker" test in this file.
|
||||
const liftedId = `${String(descriptor.id)}__lifted-on-captured`;
|
||||
engine.customModifiers.register({
|
||||
...descriptor,
|
||||
id: asCustomModifierId(liftedId),
|
||||
primitives: innerArm,
|
||||
});
|
||||
|
||||
// The Session API takes a branded `EntityId`, while
|
||||
// `findPieceIdAtSquare` returned the raw fact id (a number — that's
|
||||
// the wire-shape, not the branded engine type). Cast at this
|
||||
|
|
@ -306,14 +333,11 @@ function armCancelCaptureOnPiece(ctx: RoomCtx, defenderId: number): void {
|
|||
|
||||
engine.session.insert(defenderEntity, "OnCapturedHooks", [
|
||||
{
|
||||
// T80: include the real descriptorId so the request-choice
|
||||
// pushed inside this arm carries a resolvable id (the
|
||||
// dispatcher passes hook.descriptorId through to runPrimitives;
|
||||
// submitChoiceAndResume looks up the descriptor by id at
|
||||
// resume time). Without it the frame's descriptorId falls
|
||||
// back to the synthetic `"__trigger__"` placeholder and the
|
||||
// resume throws `runtime.descriptor-not-found`.
|
||||
descriptorId: String(descriptor.id),
|
||||
// T83: store the LIFTED descriptor's id so the dispatcher
|
||||
// threads the lifted id into the PendingChoice frame, and
|
||||
// submitChoiceAndResume's walkTriggerPath resolves the
|
||||
// request-choice at primitives[0] of the lifted descriptor.
|
||||
descriptorId: liftedId,
|
||||
target: "self",
|
||||
primitives: innerArm,
|
||||
},
|
||||
|
|
@ -517,4 +541,123 @@ describe("T81 — broadcast revert for cancel-capture (Gap H)", () => {
|
|||
teardown(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
// T83 (Wave 15) — post-resume capture restoration.
|
||||
//
|
||||
// Wave 14 (T81) shipped suppression of the mid-suspension delta;
|
||||
// T83 adds the OTHER half of the parry contract: when the
|
||||
// resumed continuation fires `cancel-capture`, the engine state
|
||||
// visible in the post-resume snapshot must reflect the FULL
|
||||
// restoration (defender back at the original square, attacker
|
||||
// back at its origin), not the post-capture state.
|
||||
//
|
||||
// Two production fixes converge here:
|
||||
// 1. `submitChoiceAndResume` synthesizes a capture event from
|
||||
// `LastCaptureSnapshot` so `cancel-capture` (which gates on
|
||||
// `ctx.event.kind === "capture"`) doesn't throw at resume.
|
||||
// 2. `handleSubmitChoice` mirrors apply.ts stage 4b's cleanup
|
||||
// after the resume: `rollbackAttackerFromSnapshot` puts the
|
||||
// attacker back on its origin square, and the snapshot +
|
||||
// CaptureCancelled flag are retracted so the next move
|
||||
// starts clean.
|
||||
it("T83: defender-wins parry submit fully restores defender + attacker in post-resume snapshot", () => {
|
||||
const ctx = setupRoom();
|
||||
try {
|
||||
sendClient(
|
||||
ctx.white,
|
||||
"game.move",
|
||||
{ from: "e2", to: "e4" },
|
||||
{ protocolVersion: 2 },
|
||||
);
|
||||
nextMsgOfType(ctx.white, "game.delta");
|
||||
nextMsgOfType(ctx.black, "game.delta");
|
||||
|
||||
sendClient(
|
||||
ctx.black,
|
||||
"game.move",
|
||||
{ from: "d7", to: "d5" },
|
||||
{ protocolVersion: 2 },
|
||||
);
|
||||
nextMsgOfType(ctx.white, "game.delta");
|
||||
nextMsgOfType(ctx.black, "game.delta");
|
||||
|
||||
const e4Square = 28;
|
||||
const d5Square = 35;
|
||||
const defenderId = findPieceIdAtSquare(ctx, d5Square);
|
||||
const attackerId = findPieceIdAtSquare(ctx, e4Square);
|
||||
expect(defenderId).toBeDefined();
|
||||
expect(attackerId).toBeDefined();
|
||||
armCancelCaptureOnPiece(ctx, defenderId!);
|
||||
|
||||
// Capture: e4xd5. Suspends on the parry rps prompt (T81
|
||||
// suppresses the post-capture delta).
|
||||
sendClient(
|
||||
ctx.white,
|
||||
"game.move",
|
||||
{ from: "e4", to: "d5" },
|
||||
{ protocolVersion: 2 },
|
||||
);
|
||||
|
||||
const rc = nextMsgOfType(ctx.white, "request-choice");
|
||||
nextMsgOfType(ctx.black, "request-choice");
|
||||
const choiceId = rc["choiceId"] as string;
|
||||
|
||||
sendV2(ctx.white, {
|
||||
kind: "submit-choice",
|
||||
protocolVersion: 2,
|
||||
choiceId,
|
||||
value: "rock",
|
||||
});
|
||||
|
||||
// Drain post-resume game.state snapshot on both sides.
|
||||
const wState = nextMsgOfType(ctx.white, "game.state");
|
||||
const bState = nextMsgOfType(ctx.black, "game.state");
|
||||
|
||||
// T83 PRIMARY ASSERTION: the snapshot reflects FULL
|
||||
// restoration. The defender (black pawn at d5) must still
|
||||
// be at d5 with PieceType=pawn, Color=black; the attacker
|
||||
// (white pawn) must be back at e4, NOT at d5.
|
||||
const wFacts = wState["payload"]!["facts"] as Array<{
|
||||
id: number;
|
||||
attr: string;
|
||||
value: unknown;
|
||||
}>;
|
||||
const findFact = (
|
||||
id: number,
|
||||
attr: string,
|
||||
): { id: number; attr: string; value: unknown } | undefined =>
|
||||
wFacts.find((f) => f.id === id && f.attr === attr);
|
||||
|
||||
// Defender restored at d5.
|
||||
const defenderPosition = findFact(defenderId!, "Position");
|
||||
expect(defenderPosition?.value).toBe(d5Square);
|
||||
const defenderType = findFact(defenderId!, "PieceType");
|
||||
expect(defenderType?.value).toBe("pawn");
|
||||
const defenderColor = findFact(defenderId!, "Color");
|
||||
expect(defenderColor?.value).toBe("black");
|
||||
|
||||
// Attacker rolled back to e4.
|
||||
const attackerPosition = findFact(attackerId!, "Position");
|
||||
expect(attackerPosition?.value).toBe(e4Square);
|
||||
|
||||
// Both clients see identical post-resume snapshots.
|
||||
expect(bState["payload"]!["facts"]).toEqual(wFacts);
|
||||
|
||||
// Engine bookkeeping cleared: PendingChoices empty,
|
||||
// CaptureCancelled flag retracted, snapshot retracted.
|
||||
const session = sessionRegistry.get(ctx.code)!;
|
||||
const engine = session.getEngine();
|
||||
expect(
|
||||
engine.session.get(GAME_ENTITY, "PendingChoices"),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
engine.session.get(GAME_ENTITY, "CaptureCancelled"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
engine.session.get(GAME_ENTITY, "LastCaptureSnapshot"),
|
||||
).toBeUndefined();
|
||||
} finally {
|
||||
teardown(ctx);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue