feat(thressgame-coverage): Wave 13 (real-pipeline integration tests + Playwright e2e)

Closes systemic gap S1 from oracle audit: parity tests now drive the REAL move pipeline, not direct primitive .apply() calls.

T78 — 8 *-real.test.ts files alongside existing parity tests:
- minefield-real, mr_freeze-real, parry-real, all_on_red-real, religious_conversion-real, ice_physics-real, kamikaze-real, mind_control-real
- Each registers descriptor via applyCustomDescriptor (production path), drives engine.applyMove, asserts engine.session state
- Existing *.test.ts files unchanged (kept as logical-semantics locks)

T79 — Playwright e2e for 3 request-choice flows:
- T68/1 single-player (mr_freeze) PASSES (1.7s) — real WS round-trip
- T68/2 both-player (mind_control) PASSES (2.8s) — 2 browser contexts
- T68/3 nested (parry) is .fixme() with documented gaps:
  * Gap G: trigger dispatcher uses synthetic descriptorId='__trigger__' that submitChoiceAndResume can't resolve
  * Gap H: cancel-capture has engine-level rollback but no compensating wire-level game.delta reversal

Production additions (minimal, test-supporting):
- GameClient declares protocolVersion=2 to receive request-choice broadcasts
- data-testid='request-choice-modal' + data-choice-kind + data-marker-kind selectors on UI
- Dev-only globalThis.__paratypeChessClient debug hook (gated on import.meta.env.DEV)
- Test-only __test__.activate-descriptor WS frame handler (gated on NODE_ENV !== production)

Tests: 2824 -> 2853 (+29 unit). Playwright e2e: 3 active pass + 1 .fixme(). bun run check exit 0. No regressions in 120-test e2e suite.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-26 16:03:59 -06:00
commit 4ec48af0f9
No known key found for this signature in database
16 changed files with 2261 additions and 442 deletions

View file

@ -63,10 +63,17 @@ import {
hasPendingChoice,
} from "./choice-timeout.js";
import {
asCustomModifierId,
GAME_ENTITY,
parseCustomModifierDescriptor,
peekPendingChoice,
PRESET_STATE_ENTITY,
pushPendingChoice,
submitChoiceAndResume,
validateProfile,
type ActionResult,
type CustomModifierDescriptor,
type EffectPrimitiveNode,
type ModifierProfile,
type ModifierValidationErrorCode,
type PendingChoice,
@ -846,6 +853,246 @@ function handleSubmitChoice(
);
}
/**
* T79 — test-only debug message handler. Lets the Playwright e2e
* suite drive the request-choice round-trip without a real
* server-side `activate-descriptor` action (gap E in
* `packages/chess/e2e/request-choice.spec.ts`'s file header).
*
* Activation contract (mirrors the unit-test cascade in
* `packages/chess/src/__fixtures__/parity/mr_freeze.test.ts`):
*
* 1. The descriptor must wrap a single `request-choice` inside an
* `on-rule-activated` block (matches the parity fixtures for
* mr_freeze + mind_control). We LIFT the inner arm so the
* request-choice is the descriptor's top-level primitive at
* index 0 — that way `submitChoiceAndResume` can resolve the
* registered descriptor by id and walk to `arm[0].params.then`
* for the continuation. The trigger dispatcher's synthetic
* `__trigger__` descriptorId path is bypassed entirely.
*
* 2. We register the lifted descriptor on the engine's
* `customModifiers` registry, set `LastModifierChooser` so
* `ctx-attr: { entity: "chooser" }` resolves, and push a
* PendingChoice frame whose descriptorId points at the lifted
* descriptor. `triggerPath=[]`, `primitiveIndex=0`,
* `bindings=Map()` because the request-choice is the top of
* the descriptor and there's no enclosing iterator scope.
*
* 3. `broadcastTopChoiceIfNew` then sends the request-choice frame
* to clients exactly the way a real on-rule-activated firing
* would.
*
* Gated to NODE_ENV !== "production" so a build-deployed server
* cannot be tricked into pushing arbitrary choice frames. The
* helper emits non-fatal INVALID_MESSAGE errors on malformed input.
*/
const TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE = "__test__.activate-descriptor";
const TEST_DEBUG_PUSH_CHOICE_TYPE = "__test__.push-pending-choice";
const TEST_DEBUG_ENABLED = process.env["NODE_ENV"] !== "production";
interface TestActivateDescriptorPayload {
descriptor: unknown;
chooserColor: "white" | "black";
liftedId?: string;
/**
* T79: roomCode is supplied in the payload (rather than read from
* `ws.data.roomCode`) so the test frame can be sent on a freshly
* opened, unauthenticated WebSocket. The two-player rooms used by
* Test 2 are already FULL, so a `room.join` from a third socket
* would be rejected with ROOM_FULL — instead we trust the
* roomCode directly because the entire `__test__.*` family is
* gated to non-production builds.
*/
roomCode: string;
}
function isTestDebugFrame(parsed: unknown): parsed is { type: string; payload: unknown } {
if (typeof parsed !== "object" || parsed === null) return false;
if (!("type" in parsed)) return false;
const t = (parsed as { type: unknown }).type;
return (
typeof t === "string" &&
(t === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE || t === TEST_DEBUG_PUSH_CHOICE_TYPE)
);
}
function liftOnRuleActivatedArm(
source: CustomModifierDescriptor,
liftedId: string,
): CustomModifierDescriptor {
const root = source.primitives[0];
if (root === undefined || root.kind !== "on-rule-activated") {
throw new Error(
"T79 test debug: descriptor.primitives[0].kind must be 'on-rule-activated'",
);
}
const innerArm = (root.params as { primitives: EffectPrimitiveNode[] })
.primitives;
return {
...source,
id: asCustomModifierId(liftedId),
primitives: innerArm,
};
}
function handleTestActivateDescriptor(
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: TestActivateDescriptorPayload;
try {
if (typeof payload !== "object" || payload === null) {
throw new Error("payload must be an object");
}
parsedPayload = payload as TestActivateDescriptorPayload;
if (
parsedPayload.chooserColor !== "white" &&
parsedPayload.chooserColor !== "black"
) {
throw new Error("chooserColor must be 'white' or 'black'");
}
if (typeof parsedPayload.roomCode !== "string") {
throw new Error("roomCode must be a string");
}
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.activate-descriptor: ${(err as Error).message}`,
false,
),
);
return;
}
const roomCode = parsedPayload.roomCode;
const session = sessionRegistry.get(roomCode);
if (!session) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.activate-descriptor: no session for room ${roomCode}`,
false,
),
);
return;
}
let descriptor: CustomModifierDescriptor;
try {
descriptor = parseCustomModifierDescriptor(parsedPayload.descriptor);
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.activate-descriptor: descriptor parse failed: ${(err as Error).message}`,
false,
),
);
return;
}
const engine = session.getEngine();
const liftedId =
parsedPayload.liftedId ?? `${String(descriptor.id)}__lifted__test`;
let lifted: CustomModifierDescriptor;
try {
lifted = liftOnRuleActivatedArm(descriptor, liftedId);
} catch (err) {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
`__test__.activate-descriptor: ${(err as Error).message}`,
false,
),
);
return;
}
engine.customModifiers.register(lifted);
// T79: relax the room's choice-timeout policy to `no-timeout` so a
// transient WS disconnect (StrictMode unmount, page navigation
// racing with the broadcast) doesn't auto-forfeit the game while
// a pending choice is on the stack. The default policy is
// `timeout-with-default(60s)` which `decideDisconnectAction` reads
// as "forfeit on disconnect" — fine for production gameplay,
// disastrous for an e2e where multiple sockets churn during the
// test setup. The policy is engine-state, so the override sticks
// for the lifetime of the room (one test).
engine.session.insert(GAME_ENTITY, "ChoiceTimeoutPolicy", {
mode: "no-timeout",
});
// Set LastModifierChooser so request-choice's chooser-aware param
// resolution (`ctx-attr: { entity: "chooser", attr: "Color" }`)
// returns the requested color.
engine.session.insert(
PRESET_STATE_ENTITY,
"LastModifierChooser",
parsedPayload.chooserColor,
);
// Read the request-choice node out of the lifted descriptor's top
// primitives list and synthesize the PendingChoice frame the
// trigger dispatcher would normally push. The dispatcher's
// __trigger__ synthetic descriptorId is replaced by the real lifted
// id so submitChoiceAndResume can find the descriptor on resume.
const requestChoiceNode = lifted.primitives[0];
if (requestChoiceNode === undefined || requestChoiceNode.kind !== "request-choice") {
sendTo(
ws,
errorMessage(
"INVALID_MESSAGE",
"__test__.activate-descriptor: lifted descriptor's primitives[0] must be 'request-choice'",
false,
),
);
return;
}
const rcParams = requestChoiceNode.params as {
kind: PendingChoice["kind"];
prompt: string;
forPlayer: PendingChoice["forPlayer"];
};
// choiceId must be deterministic enough to track but unique per push;
// use the lifted id + a process-monotonic counter so re-firing
// produces a fresh frame on the LIFO stack.
testChoiceCounter += 1;
const choiceId = `${liftedId}#${String(testChoiceCounter)}`;
pushPendingChoice(engine, {
choiceId,
descriptorId: liftedId,
triggerPath: [],
primitiveIndex: 0,
bindings: new Map(),
kind: rcParams.kind,
prompt: rcParams.prompt,
forPlayer: rcParams.forPlayer,
});
broadcastTopChoiceIfNew(roomCode, session);
void GAME_ENTITY; // silence unused import in case the lints get strict
}
let testChoiceCounter = 0;
/**
* Entry point for every inbound WS frame. Order of checks mirrors
* PROTOCOL.md §Error Handling: framing → size → parse → dispatch.
@ -859,6 +1106,28 @@ export function handleMessage(
incMessages();
const str = typeof raw === "string" ? raw : raw.toString("utf8");
// T79 — test-only debug fast-path. We try to parse the raw frame as
// JSON BEFORE the v1/v2 schema validator and route `__test__.*`
// types to the debug handlers. This sidesteps the discriminated
// union schema (which doesn't know about test types) without
// introducing a wire-protocol change.
if (TEST_DEBUG_ENABLED) {
let earlyParsed: unknown;
try {
earlyParsed = JSON.parse(str);
} catch {
earlyParsed = undefined;
}
if (isTestDebugFrame(earlyParsed)) {
const { type, payload } = earlyParsed;
if (type === TEST_DEBUG_ACTIVATE_DESCRIPTOR_TYPE) {
handleTestActivateDescriptor(ws, payload);
}
return;
}
}
void TEST_DEBUG_PUSH_CHOICE_TYPE;
const result = validateAnyMessageString(str);
if (!result.ok) {
// VERSION_MISMATCH is fatal per PROTOCOL.md; other parse failures are