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:
parent
db55f24ec8
commit
4ec48af0f9
16 changed files with 2261 additions and 442 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue