feat(thressgame-coverage): Wave 8 (WS protocol v2 + suspended execution + request-choice)
- T43: WS protocol v2 schema; protocolVersion field; RequestChoice/SubmitChoice/ProtocolVersionMismatch messages; v1 backward-compat - T44: server-side request-choice broadcast on push; submit-choice validation (kind/forPlayer/value-type); ordered LIFO matching - T45: PendingChoices stack on GAME_ENTITY; pushPendingChoice/popPendingChoice/peekPendingChoice helpers; serializePendingChoice (Map<->Array roundtrip); MAX_CHOICE_DEPTH=8 enforced - T46: submitChoiceAndResume(engine, choiceId, value); descriptor-by-id lookup; bindings restored; remaining primitives executed via runPrimitives from primitiveIndex+1 - T47: request-choice primitive; SuspendedExecution exception mechanism; dispatcher catches and stops sibling iteration; deterministic choiceId via session counter - T48: AutoChoiceResolver test transport (answersByKind / answersById); drainPendingChoices LIFO walk - T49: server-side choice timeout enforcement; auto-resolve to first-option-per-kind; disconnect handler (forfeit / pause) - T50: ChoiceTimeoutPolicy on GAME_ENTITY (timeout-with-default | no-timeout); CreateGameRequest extended; default 60s Tests: 2533 -> 2658 (+125). bun run check exit 0.
This commit is contained in:
parent
778ebc4129
commit
d4931a50ee
37 changed files with 6841 additions and 295 deletions
|
|
@ -0,0 +1,156 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import { GAME_ENTITY, type PendingChoice } from "../../schema.js";
|
||||
import { pushPendingChoice } from "../../util/pending-choices.js";
|
||||
import {
|
||||
AutoChoiceResolver,
|
||||
drainPendingChoices,
|
||||
runWithAutoResolver,
|
||||
} from "./auto-resolver.js";
|
||||
|
||||
/**
|
||||
* Helper to build a {@link PendingChoice} with sensible defaults; tests
|
||||
* override only the fields they care about. Keeps each test focused on
|
||||
* lookup behaviour rather than struct boilerplate.
|
||||
*/
|
||||
function makeChoice(overrides: Partial<PendingChoice> = {}): PendingChoice {
|
||||
return {
|
||||
choiceId: "c-default",
|
||||
descriptorId: "d-default",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
bindings: new Map(),
|
||||
kind: "rps",
|
||||
prompt: "test",
|
||||
forPlayer: "white",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("AutoChoiceResolver — lookup", () => {
|
||||
it("resolves by kind when no id-specific answer is registered", () => {
|
||||
const resolver = new AutoChoiceResolver({ rps: "rock" });
|
||||
const choice = makeChoice({ choiceId: "c1", kind: "rps" });
|
||||
expect(resolver.resolve(choice)).toBe("rock");
|
||||
});
|
||||
|
||||
it("answersById overrides answersByKind for the same frame", () => {
|
||||
const resolver = new AutoChoiceResolver(
|
||||
{ rps: "rock" },
|
||||
{ "c-special": "scissors" },
|
||||
);
|
||||
// Same kind, but the id-specific entry wins.
|
||||
const overridden = makeChoice({ choiceId: "c-special", kind: "rps" });
|
||||
expect(resolver.resolve(overridden)).toBe("scissors");
|
||||
// Other rps frames still fall back to the kind default.
|
||||
const fallback = makeChoice({ choiceId: "c-other", kind: "rps" });
|
||||
expect(resolver.resolve(fallback)).toBe("rock");
|
||||
});
|
||||
|
||||
it("treats an explicitly-registered `undefined` answer as a present entry", () => {
|
||||
// Without `hasOwnProperty` guards the resolver would skip past
|
||||
// an intentional `undefined` and look elsewhere; verify the
|
||||
// implementation distinguishes "no entry" from "entry === undefined".
|
||||
const resolver = new AutoChoiceResolver(
|
||||
{ rps: "rock" },
|
||||
{ "c-undef": undefined },
|
||||
);
|
||||
const choice = makeChoice({ choiceId: "c-undef", kind: "rps" });
|
||||
expect(resolver.resolve(choice)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws with a diagnosable message when no answer is registered", () => {
|
||||
const resolver = new AutoChoiceResolver();
|
||||
const choice = makeChoice({ choiceId: "c-missing", kind: "piece" });
|
||||
expect(() => resolver.resolve(choice)).toThrow(
|
||||
/no answer registered for choice c-missing.*kind=piece/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("drainPendingChoices — LIFO walk", () => {
|
||||
it("drains every frame innermost-first and clears the stack", () => {
|
||||
const engine = new ChessEngine();
|
||||
// Push three frames; the third is innermost and must drain first.
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "outer", kind: "rps" }));
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "middle", kind: "piece" }));
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "inner", kind: "square" }));
|
||||
|
||||
const resolver = new AutoChoiceResolver({
|
||||
rps: "rock",
|
||||
piece: 7,
|
||||
square: 28,
|
||||
});
|
||||
const drained = drainPendingChoices(engine, resolver);
|
||||
|
||||
// LIFO: inner (square) → middle (piece) → outer (rps).
|
||||
expect(drained).toEqual([28, 7, "rock"]);
|
||||
|
||||
// Stack is now empty (or never set, equivalent for our consumers).
|
||||
const stack = engine.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly PendingChoice[]
|
||||
| undefined;
|
||||
expect(stack === undefined || stack.length === 0).toBe(true);
|
||||
});
|
||||
|
||||
it("returns an empty list when no choices are pending", () => {
|
||||
const engine = new ChessEngine();
|
||||
const resolver = new AutoChoiceResolver();
|
||||
expect(drainPendingChoices(engine, resolver)).toEqual([]);
|
||||
});
|
||||
|
||||
it("propagates the resolver throw and leaves the unresolved frames in place", () => {
|
||||
const engine = new ChessEngine();
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "outer", kind: "rps" }));
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "inner", kind: "piece" }));
|
||||
|
||||
// Resolver knows about `rps` but NOT `piece`. Drain pops the
|
||||
// inner frame first → throws → outer frame survives.
|
||||
const resolver = new AutoChoiceResolver({ rps: "rock" });
|
||||
expect(() => drainPendingChoices(engine, resolver)).toThrow(
|
||||
/no answer registered for choice inner/,
|
||||
);
|
||||
const stack = engine.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly PendingChoice[]
|
||||
| undefined;
|
||||
// Both frames remain: the resolver throws BEFORE popPendingChoice
|
||||
// runs, so the inner frame survives unscathed alongside the outer.
|
||||
expect(stack?.length).toBe(2);
|
||||
expect(stack?.[0]?.choiceId).toBe("outer");
|
||||
expect(stack?.[1]?.choiceId).toBe("inner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWithAutoResolver — composed helper", () => {
|
||||
it("runs the descriptor action then drains all pushed choices", () => {
|
||||
const engine = new ChessEngine();
|
||||
const drained = runWithAutoResolver(
|
||||
engine,
|
||||
(e) => {
|
||||
// Simulated descriptor action: pushes two choices.
|
||||
pushPendingChoice(e, makeChoice({ choiceId: "first", kind: "rps" }));
|
||||
pushPendingChoice(e, makeChoice({ choiceId: "second", kind: "rps" }));
|
||||
},
|
||||
{ byKind: { rps: "paper" } },
|
||||
);
|
||||
// Both frames are kind=rps → both resolve to "paper". LIFO order:
|
||||
// second (innermost) first, then first.
|
||||
expect(drained).toEqual(["paper", "paper"]);
|
||||
});
|
||||
|
||||
it("supports byId overrides alongside byKind defaults", () => {
|
||||
const engine = new ChessEngine();
|
||||
const drained = runWithAutoResolver(
|
||||
engine,
|
||||
(e) => {
|
||||
pushPendingChoice(e, makeChoice({ choiceId: "default", kind: "rps" }));
|
||||
pushPendingChoice(e, makeChoice({ choiceId: "special", kind: "rps" }));
|
||||
},
|
||||
{
|
||||
byKind: { rps: "rock" },
|
||||
byId: { special: "scissors" },
|
||||
},
|
||||
);
|
||||
expect(drained).toEqual(["scissors", "rock"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* T48 — Deterministic auto-resolver test transport.
|
||||
*
|
||||
* A test-only "transport" that auto-submits answers for `request-choice`
|
||||
* primitives. Used by:
|
||||
* - synthetic descriptor unit tests that need to step past a choice
|
||||
* prompt without spinning up the full WS server / picker UI;
|
||||
* - Wave 10 parity tests that compare engine state after a fixed
|
||||
* descriptor + fixed choice sequence (must be byte-deterministic);
|
||||
* - e2e flows that pre-script player decisions.
|
||||
*
|
||||
* Never used in production. Lives under `__fixtures__/` so the production
|
||||
* build excludes it (Vite's tree-shake + the path-based test exclude in
|
||||
* the chess package's bundler config). The plan's must-not-do list
|
||||
* specifically bans `Math.random` here — every answer is looked up
|
||||
* from a caller-supplied table.
|
||||
*
|
||||
* ## Lookup precedence
|
||||
*
|
||||
* `answersById` overrides `answersByKind`. Authors typically populate
|
||||
* `answersByKind` for the common case ("every rps choice in this test
|
||||
* picks rock") and reach for `answersById` only when one specific
|
||||
* frame in the same test must diverge from the kind default.
|
||||
*
|
||||
* ## Integration with T46
|
||||
*
|
||||
* The full request-choice → resume cycle requires T46's
|
||||
* `submit-choice` PlayerAction handler (see `decisions.md`
|
||||
* "Player Choice — Suspended Execution"). Until T46 lands, this
|
||||
* fixture can:
|
||||
* - validate the resolver lookup logic in isolation (this file's
|
||||
* tests); and
|
||||
* - drain a stack of pre-pushed `PendingChoices` via
|
||||
* {@link drainPendingChoices}, which currently *pops* each frame
|
||||
* and consults the resolver but does NOT resume trigger
|
||||
* execution. Wave 10 parity tests will swap the pop for the
|
||||
* real `submitChoiceAndResume(engine, choiceId, value)` once
|
||||
* T46 wires it up.
|
||||
*
|
||||
* The integration gap is intentional: T48 owns the resolver shape
|
||||
* and lookup contract; T46 owns the resume mechanism. Coupling them
|
||||
* earlier would force this PR to wait on T46.
|
||||
*/
|
||||
import { GAME_ENTITY, type PendingChoice } from "../../schema.js";
|
||||
import type { ChessEngine } from "../../engine.js";
|
||||
import { popPendingChoice } from "../../util/pending-choices.js";
|
||||
|
||||
/**
|
||||
* Test-only deterministic resolver for `request-choice` frames.
|
||||
*
|
||||
* Construction accepts two answer tables:
|
||||
* - `answersByKind` — keyed by the choice's discriminator
|
||||
* (`"rps"`, `"piece"`, `"square"`, …). Use this for "every X
|
||||
* in this test answers Y" patterns.
|
||||
* - `answersById` — keyed by exact `choiceId`. Use this when one
|
||||
* particular frame must diverge from the kind default.
|
||||
*
|
||||
* Resolution precedence: `answersById` first, then `answersByKind`.
|
||||
* If neither table contains an entry for the request, `resolve`
|
||||
* throws — silent fallback (e.g. picking the first option) is
|
||||
* forbidden because it would mask test setup bugs.
|
||||
*
|
||||
* The resolver itself holds NO mutable state; calling `resolve`
|
||||
* does not consume the answer. This is deliberate: the same answer
|
||||
* may legitimately satisfy several frames (e.g. a chained `rps`
|
||||
* cascade where every prompt is `"rock"`). Tests that need
|
||||
* single-use semantics should encode that in their answer table
|
||||
* lookups directly.
|
||||
*/
|
||||
export class AutoChoiceResolver {
|
||||
constructor(
|
||||
private readonly answersByKind: Partial<
|
||||
Record<PendingChoice["kind"], unknown>
|
||||
> = {},
|
||||
private readonly answersById: Record<string, unknown> = {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Look up a deterministic answer for the given pending choice.
|
||||
*
|
||||
* Throws (rather than returning a default) when no answer is
|
||||
* registered, so a test that forgets to seed an entry fails fast
|
||||
* with a diagnosable error instead of silently using a
|
||||
* placeholder value that would corrupt downstream state.
|
||||
*/
|
||||
resolve(request: PendingChoice): unknown {
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this.answersById, request.choiceId)
|
||||
) {
|
||||
return this.answersById[request.choiceId];
|
||||
}
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this.answersByKind, request.kind)
|
||||
) {
|
||||
return this.answersByKind[request.kind];
|
||||
}
|
||||
throw new Error(
|
||||
`AutoChoiceResolver: no answer registered for choice ${request.choiceId} (kind=${request.kind})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain every currently-pending choice frame on `engine` via
|
||||
* `resolver`. Walks the stack in **strict LIFO order** (innermost
|
||||
* frame first), matching the resume contract documented in
|
||||
* `util/pending-choices.ts`: an outer arm cannot resume until every
|
||||
* nested inner choice has been answered.
|
||||
*
|
||||
* Returns the (in-order) list of resolved values so test assertions
|
||||
* can verify both *which* frames were drained and *what* values they
|
||||
* received without re-querying the resolver.
|
||||
*
|
||||
* ## Integration gap (T46-pending)
|
||||
*
|
||||
* The current implementation pops each frame and consults the
|
||||
* resolver, but does NOT call `submitChoiceAndResume` — that helper
|
||||
* doesn't exist yet (it lands in T46). When T46 ships, the pop
|
||||
* call below should be replaced with:
|
||||
*
|
||||
* ```ts
|
||||
* submitChoiceAndResume(engine, choice.choiceId, value);
|
||||
* ```
|
||||
*
|
||||
* which both pops the frame AND resumes `runPrimitives` at the
|
||||
* stored `triggerPath` + `primitiveIndex + 1`. Until then, callers
|
||||
* of `drainPendingChoices` get the lookup-and-pop behaviour only —
|
||||
* sufficient for the resolver's own tests but not for full
|
||||
* end-to-end parity scenarios.
|
||||
*/
|
||||
export function drainPendingChoices(
|
||||
engine: ChessEngine,
|
||||
resolver: AutoChoiceResolver,
|
||||
): readonly unknown[] {
|
||||
const resolved: unknown[] = [];
|
||||
// popPendingChoice always pulls the top (innermost) frame, so a
|
||||
// simple while-loop walks the stack LIFO without us needing to
|
||||
// index into it.
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const top = engine.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly PendingChoice[]
|
||||
| undefined;
|
||||
if (!top || top.length === 0) break;
|
||||
const choice = top[top.length - 1]!;
|
||||
const value = resolver.resolve(choice);
|
||||
resolved.push(value);
|
||||
popPendingChoice(engine);
|
||||
// T46 will replace the popPendingChoice call above with
|
||||
// submitChoiceAndResume(engine, choice.choiceId, value), which
|
||||
// additionally restores bindings and resumes runPrimitives.
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for the typical test pattern:
|
||||
* 1. Run a descriptor action (or any function that mutates the
|
||||
* engine and may push `PendingChoices`).
|
||||
* 2. Drain every pending choice frame using the supplied answer
|
||||
* tables.
|
||||
*
|
||||
* Returns the list of resolved values in drain order (LIFO).
|
||||
*
|
||||
* Equivalent to:
|
||||
*
|
||||
* ```ts
|
||||
* descriptorAction(engine);
|
||||
* const resolver = new AutoChoiceResolver(answersByKind, answersById);
|
||||
* return drainPendingChoices(engine, resolver);
|
||||
* ```
|
||||
*
|
||||
* Bundling the three steps removes 6 lines of boilerplate from
|
||||
* every Wave 10 parity test. Same T46 caveat applies: the drain
|
||||
* step pops without resuming until T46 lands.
|
||||
*/
|
||||
export function runWithAutoResolver(
|
||||
engine: ChessEngine,
|
||||
descriptorAction: (engine: ChessEngine) => void,
|
||||
answers: {
|
||||
readonly byKind?: Partial<Record<PendingChoice["kind"], unknown>>;
|
||||
readonly byId?: Record<string, unknown>;
|
||||
} = {},
|
||||
): readonly unknown[] {
|
||||
descriptorAction(engine);
|
||||
const resolver = new AutoChoiceResolver(
|
||||
answers.byKind ?? {},
|
||||
answers.byId ?? {},
|
||||
);
|
||||
return drainPendingChoices(engine, resolver);
|
||||
}
|
||||
64
packages/chess/src/engine.choiceTimeout.test.ts
Normal file
64
packages/chess/src/engine.choiceTimeout.test.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
/**
|
||||
* T50 — engine surface for the per-game `choiceTimeout` policy.
|
||||
*
|
||||
* Verifies that:
|
||||
* - The engine seeds the `ChoiceTimeoutPolicy` fact on `GAME_ENTITY`
|
||||
* at construction time.
|
||||
* - When the option is omitted the seeded value equals
|
||||
* `DEFAULT_CHOICE_TIMEOUT_POLICY` (`{ mode: "timeout-with-default",
|
||||
* seconds: 60 }`).
|
||||
* - Both discriminated-union variants (`timeout-with-default` and
|
||||
* `no-timeout`) round-trip through the option bag → fact write.
|
||||
*
|
||||
* The runtime CONSUMER (T49 WS-layer timer + disconnect handler) is
|
||||
* NOT exercised here — these tests cover pure construction-side
|
||||
* seeding so the policy fact is guaranteed to exist on every engine
|
||||
* instance the WS layer might bind to.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import "./presets/index.js";
|
||||
import { ChessEngine } from "./engine.js";
|
||||
import { GAME_ENTITY, DEFAULT_CHOICE_TIMEOUT_POLICY } from "./schema.js";
|
||||
|
||||
describe("ChessEngine ChoiceTimeoutPolicy seeding (T50)", () => {
|
||||
it("seeds DEFAULT_CHOICE_TIMEOUT_POLICY when no option is supplied (legacy ctor)", () => {
|
||||
const e = new ChessEngine();
|
||||
const policy = e.session.get(GAME_ENTITY, "ChoiceTimeoutPolicy");
|
||||
expect(policy).toEqual({ mode: "timeout-with-default", seconds: 60 });
|
||||
expect(policy).toEqual(DEFAULT_CHOICE_TIMEOUT_POLICY);
|
||||
});
|
||||
|
||||
it("seeds DEFAULT_CHOICE_TIMEOUT_POLICY when opts bag omits the field", () => {
|
||||
const e = new ChessEngine({});
|
||||
const policy = e.session.get(GAME_ENTITY, "ChoiceTimeoutPolicy");
|
||||
expect(policy).toEqual(DEFAULT_CHOICE_TIMEOUT_POLICY);
|
||||
});
|
||||
|
||||
it("honors explicit timeout-with-default with custom seconds", () => {
|
||||
const e = new ChessEngine({
|
||||
choiceTimeout: { mode: "timeout-with-default", seconds: 30 },
|
||||
});
|
||||
const policy = e.session.get(GAME_ENTITY, "ChoiceTimeoutPolicy");
|
||||
expect(policy).toEqual({ mode: "timeout-with-default", seconds: 30 });
|
||||
});
|
||||
|
||||
it("honors explicit no-timeout (no seconds field)", () => {
|
||||
const e = new ChessEngine({
|
||||
choiceTimeout: { mode: "no-timeout" },
|
||||
});
|
||||
const policy = e.session.get(GAME_ENTITY, "ChoiceTimeoutPolicy");
|
||||
expect(policy).toEqual({ mode: "no-timeout" });
|
||||
});
|
||||
|
||||
it("policy fact lives on GAME_ENTITY (id 0), not on a piece", () => {
|
||||
const e = new ChessEngine();
|
||||
// Sanity: facts on GAME_ENTITY include ChoiceTimeoutPolicy alongside
|
||||
// RngSeed/RngStream/Turn etc. Use allFacts() to confirm the bearer.
|
||||
const gameFacts = e.session
|
||||
.allFacts()
|
||||
.filter((f) => (f.id as number) === (GAME_ENTITY as number));
|
||||
const policyFact = gameFacts.find((f) => f.attr === "ChoiceTimeoutPolicy");
|
||||
expect(policyFact).toBeDefined();
|
||||
expect(policyFact?.value).toEqual(DEFAULT_CHOICE_TIMEOUT_POLICY);
|
||||
});
|
||||
});
|
||||
|
|
@ -9,11 +9,13 @@ import type { EntityId } from "@paratype/rete";
|
|||
import {
|
||||
GAME_ENTITY,
|
||||
PRESET_STATE_ENTITY,
|
||||
DEFAULT_CHOICE_TIMEOUT_POLICY,
|
||||
type PieceType,
|
||||
type PieceColor,
|
||||
type Square,
|
||||
type MarkerKindValue,
|
||||
type MarkerLifetimeValue,
|
||||
type ChoiceTimeoutPolicyValue,
|
||||
} from "./schema.js";
|
||||
import { applyLayout, CLASSIC_LAYOUT } from "./starting-position.js";
|
||||
import type { StartingLayout } from "./layouts/types.js";
|
||||
|
|
@ -377,6 +379,26 @@ export interface EngineOptions {
|
|||
* source; the per-draw counter is what advances during gameplay.
|
||||
*/
|
||||
readonly gameId?: string;
|
||||
/**
|
||||
* T50 — per-game choice-timeout policy. Seeded onto `GAME_ENTITY`
|
||||
* under the `ChoiceTimeoutPolicy` attr at construction time so the
|
||||
* server-side WS layer (T49) has a single authoritative source for
|
||||
* the policy. When omitted the engine seeds
|
||||
* {@link DEFAULT_CHOICE_TIMEOUT_POLICY} =
|
||||
* `{ mode: "timeout-with-default", seconds: 60 }` — matching the
|
||||
* server-side wire-schema default so an old client that doesn't
|
||||
* yet send the field still produces an engine state consistent
|
||||
* with one that does.
|
||||
*
|
||||
* The engine itself does NOT schedule any timer — it only owns the
|
||||
* fact. The runtime consumer is T49's WS-layer timer + disconnect
|
||||
* handler. Validation (`seconds >= 1`) is performed by the
|
||||
* server-side Zod schema BEFORE the value reaches the engine; this
|
||||
* field type intentionally leaves the bound off so unit tests that
|
||||
* dial timeouts down for fast simulation can pass arbitrary
|
||||
* positive integers.
|
||||
*/
|
||||
readonly choiceTimeout?: ChoiceTimeoutPolicyValue;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -576,6 +598,18 @@ export class ChessEngine {
|
|||
this.session.insert(GAME_ENTITY, "RngSeed", deriveSeedFromGameId(opts.gameId));
|
||||
this.session.insert(GAME_ENTITY, "RngStream", 0);
|
||||
|
||||
// T50 — seed the choice-timeout policy on GAME_ENTITY. Defaults to
|
||||
// DEFAULT_CHOICE_TIMEOUT_POLICY when the caller omits the option so
|
||||
// that even legacy `new ChessEngine()` callers (no opts bag) end up
|
||||
// with a deterministic policy fact the WS layer (T49) can rely on.
|
||||
// The server's Zod schema enforces `seconds >= 1`; this layer
|
||||
// trusts that prior validation and stores the value verbatim.
|
||||
this.session.insert(
|
||||
GAME_ENTITY,
|
||||
"ChoiceTimeoutPolicy",
|
||||
opts.choiceTimeout ?? DEFAULT_CHOICE_TIMEOUT_POLICY,
|
||||
);
|
||||
|
||||
// Profile seeding runs BEFORE the position is recorded for
|
||||
// threefold repetition — the modifier facts are part of the
|
||||
// "initial position" from a repetition-tracking perspective, and
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export {
|
|||
GAME_ENTITY,
|
||||
PROMOTION_PIECES,
|
||||
CaptureFlag,
|
||||
DEFAULT_CHOICE_TIMEOUT_POLICY,
|
||||
oppositeColor,
|
||||
chessFact,
|
||||
type PieceType,
|
||||
|
|
@ -28,7 +29,29 @@ export {
|
|||
type ChessAttrMap,
|
||||
type ChessAttrKey,
|
||||
type ChessFact,
|
||||
type ChoiceTimeoutPolicyValue,
|
||||
type PendingChoice,
|
||||
} from "./schema.js";
|
||||
// T44 — pending-choice helpers exported so the WS server (which owns
|
||||
// the broadcast / submit-choice validation pipeline) can introspect
|
||||
// the engine's PendingChoices stack without reaching into module-
|
||||
// private state. The helpers themselves live in `util/pending-choices.ts`.
|
||||
export {
|
||||
MAX_CHOICE_DEPTH,
|
||||
pushPendingChoice,
|
||||
popPendingChoice,
|
||||
peekPendingChoice,
|
||||
serializePendingChoice,
|
||||
deserializePendingChoice,
|
||||
// T46 — resume mechanism. Pops the top PendingChoice frame,
|
||||
// restores its bindings + binds the player's value, and re-enters
|
||||
// runPrimitives against the suspended request-choice's
|
||||
// `params.then` continuation. Exported so the server's
|
||||
// submit-choice handler (T44) can drive the resume from a single
|
||||
// public helper rather than re-implementing the descriptor walk.
|
||||
submitChoiceAndResume,
|
||||
type SerializedPendingChoice,
|
||||
} from "./util/pending-choices.js";
|
||||
export type { LegalMove } from "./rules/types.js";
|
||||
export { isInCheck } from "./rules/check.js";
|
||||
export { PRESET_REGISTRY, type PresetDef } from "./presets/index.js";
|
||||
|
|
|
|||
|
|
@ -217,6 +217,33 @@ registerAttrConsumer("LifetimeRegistry");
|
|||
// pattern of co-landing the consumer registration alongside the
|
||||
// seeding primitive when the actual reader is a future task.
|
||||
registerAttrConsumer("MoveClassRestriction");
|
||||
// T45 — LIFO stack of suspended request-choice frames, stored on
|
||||
// GAME_ENTITY. Pushed by the (forthcoming T47) `request-choice`
|
||||
// primitive when trigger execution suspends pending a player
|
||||
// decision; peeked by the (forthcoming T44) WS broadcaster to
|
||||
// surface the prompt to clients; popped by the (forthcoming T46)
|
||||
// `submit-choice` PlayerAction handler when the innermost choice
|
||||
// resolves. Helpers `pushPendingChoice`/`popPendingChoice`/
|
||||
// `peekPendingChoice` live in `util/pending-choices.ts`. Cap = 8
|
||||
// per the T0 decisions doc ("Maximum stack depth = 8") — overflow
|
||||
// throws `runtime.choice-depth-exceeded`. Registering the consumer
|
||||
// here anchors the load-time integrity check so the schema attr is
|
||||
// visible from boot even though the actual readers/writers land in
|
||||
// sibling tasks (T44/T46/T47). Mirrors the T17/T18/T38 precedent of
|
||||
// co-landing the consumer registration alongside the seeding
|
||||
// schema even when downstream consumers are deferred.
|
||||
registerAttrConsumer("PendingChoices");
|
||||
// T50 — per-game choice-timeout policy, stored on GAME_ENTITY. Seeded
|
||||
// at engine construction from EngineOptions.choiceTimeout (defaults to
|
||||
// DEFAULT_CHOICE_TIMEOUT_POLICY = `{ mode: "timeout-with-default",
|
||||
// seconds: 60 }`). The runtime CONSUMER (T49 WS-layer timer + disconnect
|
||||
// handler) lives in the server package; registering the attr here
|
||||
// anchors the load-time integrity check (`assertSeedConsumerIntegrity`)
|
||||
// so the schema attr is visible to the manifest even before T49 lands.
|
||||
// Mirrors the T16/T17/T18/T38/T45 pattern of co-landing the consumer
|
||||
// registration with the seeding side when the actual reader is owned
|
||||
// by a sibling task in the same wave.
|
||||
registerAttrConsumer("ChoiceTimeoutPolicy");
|
||||
|
||||
/**
|
||||
* Per-engine pre-move HP snapshot, used by the on-damaged trigger
|
||||
|
|
|
|||
|
|
@ -38,8 +38,24 @@ const MAX_PRIMITIVE_COUNT = 50;
|
|||
* Wave 5/6 will register them. The validator checks imperative-in-
|
||||
* passive BEFORE the unknown-kind check so descriptors authored
|
||||
* against a future runtime get a precise error code today.
|
||||
*
|
||||
* ## Type — `Set<string>` (mutable) for T20 test scaffolding
|
||||
*
|
||||
* Typed as a plain `Set<string>` (NOT `ReadonlySet<string>`) so the
|
||||
* T20 suppressTriggers test in `triggers.test.ts` can register a
|
||||
* synthetic `__t20_imperative__` kind via `add(...)` in `beforeAll`
|
||||
* + `delete(...)` in `afterAll`. Production code MUST NOT mutate
|
||||
* this set — the 10 locked kinds are the contract. The plan-amend
|
||||
* gate is enforced socially (code review), not statically — making
|
||||
* this readonly would force the test to use a less clean alternative
|
||||
* (renaming a real kind, or adding an unstable second registry).
|
||||
*
|
||||
* If you need an immutable view inside production code, take a
|
||||
* snapshot: `new Set(IMPERATIVE_KINDS)`. Production callers in this
|
||||
* codebase only `.has(...)` — never mutate — so the leakage risk is
|
||||
* already minimal.
|
||||
*/
|
||||
export const IMPERATIVE_KINDS: ReadonlySet<string> = new Set<string>([
|
||||
export const IMPERATIVE_KINDS: Set<string> = new Set<string>([
|
||||
"place-piece",
|
||||
"destroy-piece",
|
||||
"move-piece",
|
||||
|
|
|
|||
|
|
@ -77,3 +77,6 @@ import "./set-moves-also-as.js";
|
|||
|
||||
// Game-wide pawn semantics (Wave 7 — T41):
|
||||
import "./pawn-pushes-pieces.js";
|
||||
|
||||
// Player-choice suspension (Wave 8 — T47):
|
||||
import "./request-choice.js";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest";
|
|||
import { PRIMITIVE_REGISTRY } from "./index.js";
|
||||
|
||||
describe("PRIMITIVE_REGISTRY", () => {
|
||||
it("should have exactly 49 registered primitives after barrel import", () => {
|
||||
it("should have exactly 50 registered primitives after barrel import", () => {
|
||||
// T16 added "on-rule-activated"; T18 added "on-piece-entered-marker"
|
||||
// (22 → 24). T17 added "on-rule-expire" (24 → 25). T19 added
|
||||
// "on-marker-expire" (25 → 26). T21 added "place-piece" (26 → 27).
|
||||
|
|
@ -21,10 +21,11 @@ describe("PRIMITIVE_REGISTRY", () => {
|
|||
// T36 added "with-probability" (46 → 47).
|
||||
// T39 added "block-by-piece-type" (47 → 48).
|
||||
// T41 added "pawn-pushes-pieces" (48 → 49).
|
||||
// T47 added "request-choice" (49 → 50).
|
||||
// Each new primitive is a plan-amending event — bump this
|
||||
// number with intent.
|
||||
const count = PRIMITIVE_REGISTRY.list().length;
|
||||
expect(count).toBe(49);
|
||||
expect(count).toBe(50);
|
||||
});
|
||||
|
||||
it("should list all primitive kinds with non-empty descriptor objects", () => {
|
||||
|
|
|
|||
457
packages/chess/src/modifiers/primitives/request-choice.test.ts
Normal file
457
packages/chess/src/modifiers/primitives/request-choice.test.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/**
|
||||
* `request-choice` primitive (T47) — unit tests.
|
||||
*
|
||||
* Locked V1 contract:
|
||||
* 1. Registry registration under exact 'request-choice' kind
|
||||
* after the barrel side-effect import fires.
|
||||
* 2. paramsSchema accepts the documented field set
|
||||
* (kind / prompt / forPlayer / bind / then) and rejects
|
||||
* malformed kinds + empty bind names.
|
||||
* 3. apply() pushes a PendingChoice frame onto the GAME_ENTITY
|
||||
* stack with bindings snapshot + descriptorId carried over,
|
||||
* then THROWS SuspendedExecution. The frame's choiceId is
|
||||
* derived from the seeded RNG (deterministic) — `Date.now()`
|
||||
* is forbidden by the plan's must-not-do list.
|
||||
* 4. The dispatcher (`runPrimitives`) catches the throw, fixes
|
||||
* up `triggerPath` + `primitiveIndex` on the top frame, and
|
||||
* stops iterating siblings — primitives positioned AFTER the
|
||||
* request-choice in the same arm DO NOT run pre-resume.
|
||||
* 5. After T46 resume, the bind name is in scope inside `then`:
|
||||
* a continuation primitive that reads `{ $var: bind }` sees
|
||||
* the player's answer (we simulate the resume step manually
|
||||
* since T46's `submit-choice` handler isn't wired yet — the
|
||||
* test stages the bindings + re-enters runPrimitives directly
|
||||
* against the continuation arm).
|
||||
*
|
||||
* Resume simulation: T46 is a parallel sibling task in the plan
|
||||
* (locked must-not-do: don't touch T46). To exercise the
|
||||
* post-resume contract WITHOUT importing T46 we build a fresh
|
||||
* binding map containing the captured frame's bindings + the
|
||||
* player's answer under `params.bind`, then call `runPrimitives`
|
||||
* against `params.then`. This is structurally what T46 will do —
|
||||
* exercising the contract here keeps the request-choice → resume
|
||||
* path covered end-to-end before the resume helper lands.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { ChessEngine } from "../../engine.js";
|
||||
import {
|
||||
GAME_ENTITY,
|
||||
type PendingChoice,
|
||||
} from "../../schema.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import {
|
||||
REQUEST_CHOICE_PRIMITIVE,
|
||||
SuspendedExecution,
|
||||
} from "./request-choice.js";
|
||||
import { runPrimitives } from "../triggers.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
} from "./types.js";
|
||||
import type { BindingValue } from "./context.js";
|
||||
import "./request-choice.js";
|
||||
|
||||
/**
|
||||
* Test-only synthetic primitive that records its `tag` param into
|
||||
* a module-level array on every apply(). Used to verify which
|
||||
* primitives in an arm actually executed (siblings AFTER a
|
||||
* request-choice must NOT run; primitives inside the resumed
|
||||
* continuation MUST run, with the bound answer in scope).
|
||||
*
|
||||
* Registration is guarded with try/catch because vitest's
|
||||
* watch-mode re-evaluates the file on hot-reload; the registry
|
||||
* throws on duplicate kinds, so the catch swallows that.
|
||||
*/
|
||||
const RECORDER_KIND = "__t47_record__";
|
||||
const RECORDED: string[] = [];
|
||||
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
kind: RECORDER_KIND as unknown as EffectPrimitive["kind"],
|
||||
label: "T47 recorder",
|
||||
description: "Test-only stub that records params.tag on every apply.",
|
||||
paramsSchema: z.object({ tag: z.unknown() }).passthrough(),
|
||||
apply: (_ctx: PrimitiveApplyContext, params: unknown) => {
|
||||
const p = params as { tag: unknown };
|
||||
RECORDED.push(String(p.tag));
|
||||
},
|
||||
} as unknown as EffectPrimitive);
|
||||
} catch {
|
||||
// already registered (watch mode re-evaluation)
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
RECORDED.length = 0;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
RECORDED.length = 0;
|
||||
});
|
||||
|
||||
function recorderNode(tag: unknown): EffectPrimitiveNode {
|
||||
return {
|
||||
kind: RECORDER_KIND as unknown as EffectPrimitiveNode["kind"],
|
||||
params: { tag },
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(engine: ChessEngine): PrimitiveApplyContext {
|
||||
const pieceId = engine.session.nextId();
|
||||
return {
|
||||
engine,
|
||||
session: engine.session,
|
||||
pieceId,
|
||||
depth: 0,
|
||||
descriptor: {
|
||||
id: "custom:test-request-choice",
|
||||
type: "data",
|
||||
version: 1,
|
||||
},
|
||||
target: "self",
|
||||
event: undefined,
|
||||
bindings: new Map(),
|
||||
pendingTriggers: [],
|
||||
cascadeDepth: 0,
|
||||
suppressTriggers: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe("request-choice primitive — registry", () => {
|
||||
it("registers under key 'request-choice' after barrel side-effect import", () => {
|
||||
expect(PRIMITIVE_REGISTRY.has("request-choice")).toBe(true);
|
||||
expect(PRIMITIVE_REGISTRY.get("request-choice")).toBe(
|
||||
REQUEST_CHOICE_PRIMITIVE,
|
||||
);
|
||||
});
|
||||
|
||||
it("declares label 'Request Choice' and empty seedsAttrs", () => {
|
||||
expect(REQUEST_CHOICE_PRIMITIVE.label).toBe("Request Choice");
|
||||
expect(REQUEST_CHOICE_PRIMITIVE.seedsAttrs).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("request-choice primitive — paramsSchema", () => {
|
||||
it("accepts the full documented field set", () => {
|
||||
const parsed = REQUEST_CHOICE_PRIMITIVE.paramsSchema.parse({
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: [{ kind: "set-capture-flag", params: { flag: 1 } }],
|
||||
});
|
||||
expect(parsed.kind).toBe("square");
|
||||
expect(parsed.bind).toBe("sq");
|
||||
expect(parsed.then).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("rejects an empty bind name", () => {
|
||||
expect(() =>
|
||||
REQUEST_CHOICE_PRIMITIVE.paramsSchema.parse({
|
||||
kind: "square",
|
||||
prompt: "Pick",
|
||||
forPlayer: "white",
|
||||
bind: "",
|
||||
then: [],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unrecognised kind", () => {
|
||||
expect(() =>
|
||||
REQUEST_CHOICE_PRIMITIVE.paramsSchema.parse({
|
||||
// Invalid kind on purpose — the schema must reject any
|
||||
// value outside the locked enum.
|
||||
kind: "elephant",
|
||||
prompt: "Pick",
|
||||
forPlayer: "white",
|
||||
bind: "x",
|
||||
then: [],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("accepts every kind in the locked enum", () => {
|
||||
for (const kind of ["rps", "piece", "square", "column", "row"] as const) {
|
||||
expect(() =>
|
||||
REQUEST_CHOICE_PRIMITIVE.paramsSchema.parse({
|
||||
kind,
|
||||
prompt: "Pick",
|
||||
forPlayer: "both",
|
||||
bind: "x",
|
||||
then: [],
|
||||
}),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("request-choice primitive — apply()", () => {
|
||||
it("pushes a PendingChoice frame and throws SuspendedExecution", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(1234);
|
||||
const ctx = makeContext(engine);
|
||||
|
||||
expect(() =>
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(ctx, {
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: [],
|
||||
}),
|
||||
).toThrow(SuspendedExecution);
|
||||
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[] | undefined;
|
||||
expect(stack).toBeDefined();
|
||||
expect(stack!).toHaveLength(1);
|
||||
|
||||
const top = stack![0]!;
|
||||
expect(top.kind).toBe("square");
|
||||
expect(top.prompt).toBe("Pick a square");
|
||||
expect(top.forPlayer).toBe("white");
|
||||
expect(top.descriptorId).toBe(ctx.descriptor.id);
|
||||
// choiceId must be deterministic — derived from the seeded RNG.
|
||||
// It must NOT contain a timestamp pattern (Date.now() is
|
||||
// forbidden by the plan's must-not-do list).
|
||||
expect(top.choiceId).toMatch(
|
||||
/^choice-custom:test-request-choice-[0-9a-f]{8}$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("captures the current bindings into the pushed frame", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(5);
|
||||
const baseCtx = makeContext(engine);
|
||||
const ctx: PrimitiveApplyContext = {
|
||||
...baseCtx,
|
||||
bindings: new Map<string, BindingValue>([
|
||||
["chooser", 42],
|
||||
["target", 28],
|
||||
]),
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(ctx, {
|
||||
kind: "rps",
|
||||
prompt: "Throw",
|
||||
forPlayer: "both",
|
||||
bind: "throw",
|
||||
then: [],
|
||||
}),
|
||||
).toThrow(SuspendedExecution);
|
||||
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
const top = stack[0]!;
|
||||
expect(top.bindings.get("chooser")).toBe(42);
|
||||
expect(top.bindings.get("target")).toBe(28);
|
||||
});
|
||||
|
||||
it("derives a deterministic choiceId across two engines seeded the same way", () => {
|
||||
const a = new ChessEngine();
|
||||
a.setRngSeed(99);
|
||||
const b = new ChessEngine();
|
||||
b.setRngSeed(99);
|
||||
|
||||
let firstId = "";
|
||||
let secondId = "";
|
||||
try {
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(makeContext(a), {
|
||||
kind: "rps",
|
||||
prompt: "p",
|
||||
forPlayer: "both",
|
||||
bind: "x",
|
||||
then: [],
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SuspendedExecution) firstId = e.choice.choiceId;
|
||||
}
|
||||
try {
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(makeContext(b), {
|
||||
kind: "rps",
|
||||
prompt: "p",
|
||||
forPlayer: "both",
|
||||
bind: "x",
|
||||
then: [],
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof SuspendedExecution) secondId = e.choice.choiceId;
|
||||
}
|
||||
expect(firstId.length).toBeGreaterThan(0);
|
||||
expect(firstId).toBe(secondId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("request-choice primitive — runPrimitives integration", () => {
|
||||
it("dispatcher stops iterating siblings AFTER request-choice", () => {
|
||||
RECORDED.length = 0;
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(7);
|
||||
const pieceId = engine.session.nextId();
|
||||
|
||||
// An arm with three nodes: a recorder, a request-choice, and
|
||||
// another recorder. Only the FIRST recorder must run; the
|
||||
// request-choice suspends and the third node must NOT execute.
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
recorderNode("before"),
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "Pick",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: [recorderNode("continuation")],
|
||||
},
|
||||
},
|
||||
recorderNode("after"),
|
||||
];
|
||||
|
||||
runPrimitives(engine, pieceId, nodes, 0);
|
||||
|
||||
expect(RECORDED).toEqual(["before"]);
|
||||
// Continuation didn't run yet either — it runs only after T46
|
||||
// resumes with a player answer.
|
||||
expect(RECORDED).not.toContain("continuation");
|
||||
expect(RECORDED).not.toContain("after");
|
||||
|
||||
// The pending stack is non-empty (the suspended frame is on top).
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(stack).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("dispatcher records triggerPath + primitiveIndex on the suspended frame", () => {
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(11);
|
||||
const pieceId = engine.session.nextId();
|
||||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
recorderNode("a"),
|
||||
recorderNode("b"),
|
||||
{
|
||||
// index 2 in the top-level arm
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "rps",
|
||||
prompt: "Throw",
|
||||
forPlayer: "both",
|
||||
bind: "throw",
|
||||
then: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
runPrimitives(engine, pieceId, nodes, 0);
|
||||
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(stack).toHaveLength(1);
|
||||
const top = stack[0]!;
|
||||
// Top-level arm => empty triggerPath, primitiveIndex = 2.
|
||||
expect(top.triggerPath).toEqual([]);
|
||||
expect(top.primitiveIndex).toBe(2);
|
||||
});
|
||||
|
||||
it("after simulated T46 resume, the $var bind name is in scope inside `then`", () => {
|
||||
RECORDED.length = 0;
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(3);
|
||||
const ctx = makeContext(engine);
|
||||
const pieceId = ctx.pieceId;
|
||||
|
||||
// Stage 1 — apply request-choice DIRECTLY (not via runPrimitives)
|
||||
// so the param walker doesn't eagerly recurse into the `then`
|
||||
// continuation. The walker invoked by `runPrimitives` is
|
||||
// exhaustive: it would try to resolve `{ $var: "sq" }` BEFORE
|
||||
// the bind name is introduced — same shape limitation that
|
||||
// affects `for-each-piece` when the walker pre-resolves outer
|
||||
// params. The suspension contract itself is independent of
|
||||
// walker timing (covered in the "dispatcher stops" test); here
|
||||
// we focus on the resume-time scope behaviour.
|
||||
const continuation: EffectPrimitiveNode[] = [
|
||||
recorderNode({ $var: "sq" }),
|
||||
];
|
||||
try {
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(ctx, {
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: continuation,
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof SuspendedExecution)) throw e;
|
||||
}
|
||||
expect(RECORDED).toEqual([]); // suspended — `then` hasn't run yet
|
||||
|
||||
// Stage 2 — simulate T46 resume. Pull the suspended frame, build
|
||||
// a fresh bindings map containing the frame's snapshot + the
|
||||
// player's answer under params.bind, and re-enter runPrimitives
|
||||
// against the captured continuation. This is structurally what
|
||||
// T46's submit-choice handler will do: pop, restore bindings,
|
||||
// inject the answer, re-enter.
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
const top = stack[0]!;
|
||||
const playerAnswer = 28; // square e4
|
||||
const resumed = new Map<string, BindingValue>(
|
||||
top.bindings as ReadonlyMap<string, BindingValue>,
|
||||
);
|
||||
resumed.set("sq", playerAnswer);
|
||||
|
||||
runPrimitives(engine, pieceId, continuation, 1, undefined, resumed);
|
||||
|
||||
// The recorder ran with the bound value resolved. T12's param
|
||||
// walker substituted `{ $var: "sq" }` → 28 before apply().
|
||||
expect(RECORDED).toEqual(["28"]);
|
||||
});
|
||||
|
||||
it("allows the suspended frame to be popped + re-fired (caps depth at 8)", () => {
|
||||
// Defensive coverage: the suspension path must compose with the
|
||||
// T45 depth cap. Pushing 8 frames via 8 separate request-choice
|
||||
// applies works; a 9th throws `runtime.choice-depth-exceeded`.
|
||||
const engine = new ChessEngine();
|
||||
engine.setRngSeed(13);
|
||||
const ctx = makeContext(engine);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
try {
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(ctx, {
|
||||
kind: "rps",
|
||||
prompt: "p",
|
||||
forPlayer: "both",
|
||||
bind: "x",
|
||||
then: [],
|
||||
});
|
||||
} catch (e) {
|
||||
if (!(e instanceof SuspendedExecution)) throw e;
|
||||
}
|
||||
}
|
||||
// Stack is at the cap; the 9th push throws. The throw is the
|
||||
// depth-exceed error (NOT SuspendedExecution) because
|
||||
// pushPendingChoice fires the depth check BEFORE
|
||||
// request-choice's throw lands.
|
||||
expect(() =>
|
||||
REQUEST_CHOICE_PRIMITIVE.apply(ctx, {
|
||||
kind: "rps",
|
||||
prompt: "p",
|
||||
forPlayer: "both",
|
||||
bind: "x",
|
||||
then: [],
|
||||
}),
|
||||
).toThrow(/runtime\.choice-depth-exceeded/);
|
||||
});
|
||||
});
|
||||
295
packages/chess/src/modifiers/primitives/request-choice.ts
Normal file
295
packages/chess/src/modifiers/primitives/request-choice.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
/**
|
||||
* `request-choice` primitive (T47).
|
||||
*
|
||||
* Suspends trigger execution until a player answers a UI prompt.
|
||||
* Pushes a {@link PendingChoice} frame onto the LIFO stack on
|
||||
* `GAME_ENTITY` (T45) and short-circuits the dispatcher by throwing
|
||||
* {@link SuspendedExecution}. The dispatcher (`runPrimitives` in
|
||||
* `triggers.ts`) catches the exception, fills in the missing
|
||||
* `triggerPath` + `primitiveIndex` on the just-pushed frame, and
|
||||
* stops iterating siblings — the rest of the surrounding arm is the
|
||||
* "continuation" that T46 (`submit-choice`) will resume after the
|
||||
* player picks.
|
||||
*
|
||||
* ## Why a thrown exception, not a return flag
|
||||
*
|
||||
* runPrimitives loops over `nodes[]`, calling each primitive's
|
||||
* `apply()`. If apply() simply returned `void`, the loop would
|
||||
* silently advance to the next sibling — the request-choice would
|
||||
* push its frame and then the next sibling would still run, which
|
||||
* is the OPPOSITE of suspension. The two ways to abort the loop
|
||||
* cleanly are (a) a thrown exception caught at the dispatcher, or
|
||||
* (b) a mutable side-channel on `ctx`. Option (a) is preferred
|
||||
* here because it doesn't widen the public `PrimitiveApplyContext`
|
||||
* surface with a new mutable field that every other primitive then
|
||||
* has to ignore. See plan T47 § "Suspension mechanism".
|
||||
*
|
||||
* ## Why `triggerPath` + `primitiveIndex` come from the dispatcher
|
||||
*
|
||||
* The primitive itself does NOT know its own index inside the
|
||||
* arm that's iterating it (the loop counter lives in
|
||||
* `runPrimitives`), nor does it know the path of nested
|
||||
* `then` / `else` / `primitives` slots leading to the current arm.
|
||||
* The dispatcher owns both. So the primitive pushes a frame with
|
||||
* placeholders (`triggerPath: []`, `primitiveIndex: 0`); the
|
||||
* dispatcher's catch-block pops the placeholder, replaces those
|
||||
* two fields with the real values, and re-pushes. This keeps the
|
||||
* primitive's `apply()` contract free of dispatcher-internal state
|
||||
* (it never reads or writes the loop counter directly).
|
||||
*
|
||||
* ## Deterministic `choiceId`
|
||||
*
|
||||
* `Date.now()` is forbidden by the plan's must-not-do list — would
|
||||
* make the id wall-clock-dependent and break replay. We instead use
|
||||
* `engine.rng().nextInt(...)` which advances the persistent
|
||||
* `RngStream` fact on `GAME_ENTITY`. Two engines seeded identically
|
||||
* and run through the same descriptor sequence produce the same id
|
||||
* for the same choice (the locked T2 determinism contract).
|
||||
*
|
||||
* The id format is `choice-<descriptorId>-<rngHex>` so:
|
||||
* - `descriptorId` makes the id self-describing in logs.
|
||||
* - `rngHex` is a hex-encoded uint32 from the seeded RNG —
|
||||
* uniqueness within a session is bounded by 2^32, sufficient
|
||||
* for any plausible game (a typical game pushes < 100 choice
|
||||
* frames; collision odds at that scale are negligible).
|
||||
*
|
||||
* ## Bindings
|
||||
*
|
||||
* The frame captures `ctx.bindings` at suspension time so the
|
||||
* resume mechanism (T46) can rebuild a context that observes every
|
||||
* outer iteration's `$var` scope. The frame's `bind` field (the
|
||||
* name the player's answer will land under) is NOT part of
|
||||
* `PendingChoice` — it's stashed in the captured `bindings` map
|
||||
* via convention: T46 looks up `frame.bindings.get(<bind>)` after
|
||||
* inserting the player's answer under `params.bind`.
|
||||
*
|
||||
* Wait — actually the schema's `bindings` field is the SCOPE at
|
||||
* suspension. The player's answer key (`params.bind`) is recorded
|
||||
* separately in the {@link PendingChoice}? No — `PendingChoice`
|
||||
* doesn't carry `bind`. The convention is: T46 reads the
|
||||
* descriptor's primitive tree at `triggerPath`, finds the
|
||||
* request-choice node, reads its `params.bind`, and writes
|
||||
* `bindings.set(params.bind, playerAnswer)` before re-entering
|
||||
* `runPrimitives`. That keeps the `PendingChoice` shape minimal.
|
||||
*
|
||||
* ## Imperative gating (T20)
|
||||
*
|
||||
* `request-choice` is NOT in `IMPERATIVE_KINDS` — it's a
|
||||
* control-flow primitive, not a board mutator. Dry-mode probing
|
||||
* still calls `apply()`, which means a what-if probe would push a
|
||||
* pending frame and throw. That would corrupt the dry probe's
|
||||
* state. The validator (T34) is responsible for forbidding
|
||||
* `request-choice` inside primitives that the dry-prober walks
|
||||
* through (e.g. inside `with-probability`'s arms — already locked
|
||||
* by T34); for the dispatcher level, dry-mode never enters trigger
|
||||
* dispatch in the first place (move-gen runs `attackProbe`, not
|
||||
* the trigger pipeline). So in practice this primitive only fires
|
||||
* on the wet path.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import { PRIMITIVE_REGISTRY } from "./registry.js";
|
||||
import { pushPendingChoice } from "../../util/pending-choices.js";
|
||||
import type { PendingChoice } from "../../schema.js";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
EffectPrimitiveNode,
|
||||
PrimitiveApplyContext,
|
||||
PrimitiveKind,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* Thrown by `request-choice`'s `apply()` to signal that trigger
|
||||
* execution must SUSPEND. Caught by `runPrimitives` in
|
||||
* `triggers.ts`, which:
|
||||
* 1. Reads the just-pushed `PendingChoice` from the top of the
|
||||
* stack (the primitive pushed it before throwing).
|
||||
* 2. Populates `triggerPath` + `primitiveIndex` (which the
|
||||
* primitive itself can't know — see file docstring).
|
||||
* 3. Stops iterating sibling primitives.
|
||||
*
|
||||
* The exception is intentionally a distinct subclass (not a plain
|
||||
* `Error`) so the catch-block can `instanceof`-discriminate from
|
||||
* actual error conditions like `runtime.choice-depth-exceeded`
|
||||
* (which should propagate, not be silently swallowed).
|
||||
*/
|
||||
export class SuspendedExecution extends Error {
|
||||
readonly choice: PendingChoice;
|
||||
constructor(choice: PendingChoice) {
|
||||
super(`execution suspended at choice ${choice.choiceId}`);
|
||||
this.name = "SuspendedExecution";
|
||||
this.choice = choice;
|
||||
// Cross-module `instanceof` defence (mirrors BindingError in
|
||||
// param-resolver.ts). Some bundler configs duplicate class
|
||||
// identity across module boundaries; resetting the prototype
|
||||
// explicitly keeps `instanceof` honest in those builds.
|
||||
Object.setPrototypeOf(this, SuspendedExecution.prototype);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline NodeSchema (mirrors `with-probability.ts` /
|
||||
* `for-each-piece.ts`). The tree validator handles deep
|
||||
* kind-validation; here we only assert the structural
|
||||
* `{ kind, params }` shape.
|
||||
*/
|
||||
const NodeSchema: z.ZodType<EffectPrimitiveNode> = z.object({
|
||||
kind: z.string() as z.ZodType<PrimitiveKind>,
|
||||
params: z.unknown(),
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
/**
|
||||
* Discriminator for the kind of decision the player makes.
|
||||
* Drives the client-side picker UI: `rps` shows three tap
|
||||
* targets, `square` highlights the board, etc. Locked enum —
|
||||
* adding a new kind requires a `decisions.md` amendment.
|
||||
*/
|
||||
kind: z.enum(["rps", "piece", "square", "column", "row"]),
|
||||
/**
|
||||
* Human-readable question text shown alongside the picker.
|
||||
* E.g. "Which file does the spy reveal?".
|
||||
*/
|
||||
prompt: z.string(),
|
||||
/**
|
||||
* Which side may answer. `"both"` covers either-player prompts
|
||||
* (coin-flip / cooperative ceremonies). The `submit-choice`
|
||||
* handler (T46) rejects responses from the wrong side.
|
||||
*/
|
||||
forPlayer: z.enum(["white", "black", "both"]),
|
||||
/**
|
||||
* Lexical-binding name — after the player answers, T46 inserts
|
||||
* their value into `bindings` under this key so subsequent
|
||||
* primitives in `then` (and any nested arms) can read it via
|
||||
* `{ $var: "<bind>" }`. Must be non-empty.
|
||||
*/
|
||||
bind: z.string().min(1),
|
||||
/**
|
||||
* Continuation primitives — the rest of the arm that runs AFTER
|
||||
* the player answers. Stored in the descriptor tree under this
|
||||
* primitive's params; T46 picks them up by re-entering
|
||||
* `runPrimitives` against this list with the resumed context.
|
||||
*/
|
||||
then: z.array(NodeSchema),
|
||||
});
|
||||
type Params = z.infer<typeof schema>;
|
||||
|
||||
const descriptor: EffectPrimitive<Params> = {
|
||||
kind: "request-choice",
|
||||
label: "Request Choice",
|
||||
description:
|
||||
"Suspends trigger execution until a player answers a UI prompt; binds the answer for subsequent primitives.",
|
||||
longDescription:
|
||||
"Pushes a PendingChoice frame onto the LIFO stack on GAME_ENTITY (T45) and short-circuits the dispatcher via the SuspendedExecution exception. The dispatcher (`runPrimitives`) catches the throw, fills in `triggerPath` + `primitiveIndex` on the pushed frame, and stops iterating siblings of the current arm. The rest of the arm is the SUSPENDED CONTINUATION — T46 (`submit-choice` PlayerAction) restores the captured bindings, inserts the player's answer under `params.bind`, and re-enters `runPrimitives` against `params.then` so the continuation runs with the answer in scope. The `choiceId` is derived from the engine's seeded RNG so replays produce identical ids; `Date.now()` is intentionally forbidden.",
|
||||
examples: [
|
||||
{
|
||||
title: "Pick a square to mine",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "Pick a square to plant a mine",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
square: { $var: "sq" },
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"Suspends until white picks a square; the chosen square is bound to `$sq` and spawns a mine there. The continuation runs only after T46 resumes with the player's answer.",
|
||||
},
|
||||
{
|
||||
title: "RPS coin-flip ceremony",
|
||||
params: {
|
||||
kind: "rps",
|
||||
prompt: "Pick rock, paper, or scissors",
|
||||
forPlayer: "both",
|
||||
bind: "throw",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: {
|
||||
target: "self",
|
||||
attr: "Hp",
|
||||
value: { $var: "throw" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
effect:
|
||||
"Either player may answer; their throw is bound to `$throw` and written into the target's Hp attr after T46 resumes the continuation.",
|
||||
},
|
||||
],
|
||||
paramsSchema: schema,
|
||||
// No attr seeded — request-choice is a control-flow orchestrator,
|
||||
// not a writer. The PendingChoices stack lives on GAME_ENTITY but
|
||||
// is mutated via util/pending-choices.ts helpers, not as a
|
||||
// declared `seedsAttrs` (the consumer is registered in apply.ts
|
||||
// already — see registerAttrConsumer("PendingChoices")).
|
||||
seedsAttrs: [],
|
||||
apply(ctx: PrimitiveApplyContext, params: Params): void {
|
||||
// Phase 1 — derive a deterministic choice id. nextInt advances
|
||||
// the persistent RngStream by 1, so the id is reproducible from
|
||||
// (RngSeed, RngStream-at-push-time). Two engines seeded the
|
||||
// same way and run through the same descriptor sequence
|
||||
// produce the same id — the locked T2 determinism contract.
|
||||
//
|
||||
// 2^31 is the largest value SeededRng.nextInt accepts safely
|
||||
// (it multiplies by Math.floor(next() * max) and uses int32
|
||||
// arithmetic upstream); using the full 32-bit range gives
|
||||
// enough entropy that collisions within a session are
|
||||
// negligible (a typical game pushes < 100 choices).
|
||||
const idNum = ctx.engine.rng().nextInt(0x7fffffff);
|
||||
const idHex = idNum.toString(16).padStart(8, "0");
|
||||
const choiceId = `choice-${ctx.descriptor.id}-${idHex}`;
|
||||
|
||||
// Phase 2 — build the frame. `triggerPath` and `primitiveIndex`
|
||||
// are placeholders; the dispatcher's catch-block in
|
||||
// `runPrimitives` (triggers.ts) overwrites them with the real
|
||||
// values before T46 sees the frame. We push WITH the
|
||||
// placeholders so the cap-check in pushPendingChoice (depth
|
||||
// ≤ 8) fires on the actual count, not a phantom pre-push count.
|
||||
const choice: PendingChoice = {
|
||||
choiceId,
|
||||
descriptorId: ctx.descriptor.id,
|
||||
// Placeholders — populated by runPrimitives' catch-block.
|
||||
// See file docstring § "Why triggerPath + primitiveIndex
|
||||
// come from the dispatcher". `primitiveIndex: -1` is a
|
||||
// SENTINEL: a real primitive index is always >= 0, so the
|
||||
// dispatcher can use this exact value to discriminate "I am
|
||||
// the innermost catch and own the fix-up" from "an outer
|
||||
// ancestor catch — frame already populated, leave alone".
|
||||
triggerPath: [],
|
||||
primitiveIndex: -1,
|
||||
// Snapshot the lexical scope at suspension. T46 rebuilds a
|
||||
// ctx from this map (plus the player's answer under
|
||||
// params.bind) when resuming the continuation.
|
||||
bindings: new Map(ctx.bindings),
|
||||
kind: params.kind,
|
||||
prompt: params.prompt,
|
||||
forPlayer: params.forPlayer,
|
||||
};
|
||||
|
||||
// Phase 3 — push + suspend. pushPendingChoice enforces the
|
||||
// depth cap (MAX_CHOICE_DEPTH = 8); breach throws
|
||||
// `runtime.choice-depth-exceeded` which propagates uncaught
|
||||
// through the dispatcher (intentional — that's a hard error,
|
||||
// not a normal suspension). The throw below is caught by
|
||||
// runPrimitives and treated as the suspension signal.
|
||||
pushPendingChoice(ctx.engine, choice);
|
||||
throw new SuspendedExecution(choice);
|
||||
},
|
||||
childPrimitives(params: Params): EffectPrimitiveNode[] {
|
||||
// The continuation is the only nested list. Tree-walkers
|
||||
// (validator T13 binding-scope walker, manifest cleanup) need
|
||||
// to recurse into it to discover nested seeds / `$var` refs.
|
||||
return [...params.then];
|
||||
},
|
||||
};
|
||||
|
||||
PRIMITIVE_REGISTRY.register(descriptor);
|
||||
export { descriptor as REQUEST_CHOICE_PRIMITIVE };
|
||||
|
|
@ -102,7 +102,8 @@ export type PrimitiveKind =
|
|||
| "conditional"
|
||||
| "must-class"
|
||||
| "block-by-piece-type"
|
||||
| "pawn-pushes-pieces";
|
||||
| "pawn-pushes-pieces"
|
||||
| "request-choice";
|
||||
|
||||
/**
|
||||
* Forward-declared shape of the back-reference passed to primitive
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* apply moves through the engine, asserting the inner primitives
|
||||
* actually ran.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import type { EntityId } from "@paratype/rete";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { GAME_ENTITY } from "../schema.js";
|
||||
|
|
@ -28,6 +28,7 @@ import {
|
|||
type PreMoveCheckStateLike,
|
||||
} from "./triggers.js";
|
||||
import { PRIMITIVE_REGISTRY } from "./primitives/registry.js";
|
||||
import { IMPERATIVE_KINDS } from "./custom/validate.js";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
EffectPrimitive,
|
||||
|
|
@ -656,42 +657,43 @@ describe("fireOnCapturedHooks", () => {
|
|||
// run normally so legality analysis can branch on the same data the
|
||||
// wet path would.
|
||||
//
|
||||
// IMPERATIVE_KINDS (T14, locked at T0 ADR) = 10 future Wave-5/6 kinds:
|
||||
// IMPERATIVE_KINDS (T14, locked at T0 ADR) = 10 Wave-5/6 kinds:
|
||||
// place-piece, destroy-piece, move-piece, swap-pieces,
|
||||
// convert-piece-type, set-piece-attr, cancel-capture, spawn-marker,
|
||||
// spawn-marker-pair, destroy-marker.
|
||||
// None are registered yet; the suite below registers a SYNTHETIC
|
||||
// primitive under one of those kind-names so the gate can be exercised
|
||||
// today without waiting for Wave 5/6 implementations.
|
||||
// As of T29 all 10 are registered by real primitives (T21-T30), so the
|
||||
// suite below uses a TEST-ONLY synthetic kind (`__t20_imperative__`)
|
||||
// added to IMPERATIVE_KINDS via `beforeAll` and removed in `afterAll`,
|
||||
// so the gate can be exercised without colliding with any production
|
||||
// apply().
|
||||
|
||||
describe("move-gen suppressTriggers flag (T20)", () => {
|
||||
// Track imperative-primitive side effects via a module-scoped flag.
|
||||
// Each test resets it via the per-test setup. The synthetic primitive
|
||||
// is registered ONCE at first describe entry — `PRIMITIVE_REGISTRY`
|
||||
// has no unregister, but using a kind-name from IMPERATIVE_KINDS
|
||||
// (`swap-pieces`) doesn't collide because that Wave 5 task hasn't
|
||||
// landed yet.
|
||||
// Each test resets it via the per-test setup.
|
||||
let imperativeFired = false;
|
||||
let predicateFired = false;
|
||||
|
||||
// Register the synthetic imperative primitive on first entry. The
|
||||
// try/catch handles repeat registrations from test re-runs (vitest
|
||||
// module re-evaluation in watch mode would otherwise throw on the
|
||||
// duplicate-kind guard).
|
||||
// T29 closure — every kind in IMPERATIVE_KINDS is now registered by
|
||||
// a real primitive (T21-T30), so we can no longer borrow an unused
|
||||
// locked kind as the synthetic stub. Instead we register a TEST-ONLY
|
||||
// kind `__t20_imperative__` (underscore-prefixed = not a contract
|
||||
// name) and ADD it to the IMPERATIVE_KINDS set in `beforeAll` /
|
||||
// remove it in `afterAll` so the dispatcher's `IMPERATIVE_KINDS.has`
|
||||
// gate fires for it. IMPERATIVE_KINDS is intentionally typed as a
|
||||
// mutable `Set<string>` to support exactly this scaffolding (see
|
||||
// `validate.ts` § Type — `Set<string>` for the rationale).
|
||||
//
|
||||
// Uses `spawn-marker-pair` — still in IMPERATIVE_KINDS (locked at T0
|
||||
// ADR) but not yet registered as a real primitive (Wave 6 / T29 will
|
||||
// add it). T21-T27 (Wave 5) landed real implementations for the other
|
||||
// kinds, so reusing those kinds here would collide with the real
|
||||
// apply() and miss the synthetic flag.
|
||||
// Registry-level registration happens once at module load (try/catch
|
||||
// guards re-evaluation in watch mode); the IMPERATIVE_KINDS membership
|
||||
// is scoped to this describe block via beforeAll/afterAll so the
|
||||
// synthetic gate doesn't leak into other suites.
|
||||
const SYNTHETIC_IMPERATIVE_KIND = "__t20_imperative__";
|
||||
try {
|
||||
PRIMITIVE_REGISTRY.register({
|
||||
// Cast through unknown — the registry's PrimitiveKind union does
|
||||
// NOT include `spawn-marker-pair` yet (T29 will add it). The
|
||||
// runtime registry stores the kind as a plain string key, so the
|
||||
// lookup in `runPrimitives` works regardless of static typing.
|
||||
kind: "spawn-marker-pair" as unknown as EffectPrimitive["kind"],
|
||||
label: "T20 synthetic spawn-marker-pair",
|
||||
// NOT include `__t20_imperative__` (test-only string).
|
||||
kind: SYNTHETIC_IMPERATIVE_KIND as unknown as EffectPrimitive["kind"],
|
||||
label: "T20 synthetic imperative",
|
||||
description: "Test-only stub for the suppressTriggers gate.",
|
||||
paramsSchema: z.object({}).passthrough(),
|
||||
apply: () => {
|
||||
|
|
@ -702,6 +704,19 @@ describe("move-gen suppressTriggers flag (T20)", () => {
|
|||
// already registered (test file re-evaluated)
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// Add the synthetic kind to the IMPERATIVE_KINDS set so the
|
||||
// dispatcher's gate (`IMPERATIVE_KINDS.has(node.kind)`) recognises
|
||||
// it. The 10 locked kinds remain unaffected — Set.add is idempotent.
|
||||
IMPERATIVE_KINDS.add(SYNTHETIC_IMPERATIVE_KIND);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
// Restore the locked-10 set so other test suites (and any
|
||||
// subsequent describe block) see the production contract.
|
||||
IMPERATIVE_KINDS.delete(SYNTHETIC_IMPERATIVE_KIND);
|
||||
});
|
||||
|
||||
// Register a synthetic NON-imperative primitive whose kind is NOT in
|
||||
// IMPERATIVE_KINDS — used to prove that suppressTriggers does NOT
|
||||
// affect non-imperative primitives. Using a fresh kind-name avoids
|
||||
|
|
@ -740,8 +755,9 @@ describe("move-gen suppressTriggers flag (T20)", () => {
|
|||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
// Cast: kind is in IMPERATIVE_KINDS but not in PrimitiveKind union.
|
||||
kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"],
|
||||
// Cast: kind is in IMPERATIVE_KINDS (extended via beforeAll)
|
||||
// but not in the static PrimitiveKind union.
|
||||
kind: SYNTHETIC_IMPERATIVE_KIND as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
|
@ -760,7 +776,7 @@ describe("move-gen suppressTriggers flag (T20)", () => {
|
|||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"],
|
||||
kind: SYNTHETIC_IMPERATIVE_KIND as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
|
@ -798,7 +814,7 @@ describe("move-gen suppressTriggers flag (T20)", () => {
|
|||
params: {},
|
||||
},
|
||||
{
|
||||
kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"],
|
||||
kind: SYNTHETIC_IMPERATIVE_KIND as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
|
@ -815,7 +831,7 @@ describe("move-gen suppressTriggers flag (T20)", () => {
|
|||
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "spawn-marker-pair" as unknown as EffectPrimitiveNode["kind"],
|
||||
kind: SYNTHETIC_IMPERATIVE_KIND as unknown as EffectPrimitiveNode["kind"],
|
||||
params: {},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -79,6 +79,11 @@ import type {
|
|||
PrimitiveApplyContext,
|
||||
} from "./primitives/types.js";
|
||||
import { IMPERATIVE_KINDS } from "./custom/validate.js";
|
||||
import { SuspendedExecution } from "./primitives/request-choice.js";
|
||||
import {
|
||||
popPendingChoice,
|
||||
pushPendingChoice,
|
||||
} from "../util/pending-choices.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
|
||||
/**
|
||||
|
|
@ -154,6 +159,22 @@ export function runPrimitives(
|
|||
bindings: ReadonlyMap<string, BindingValue> = new Map(),
|
||||
cascadeDepth: number = 0,
|
||||
suppressTriggers: boolean = false,
|
||||
/**
|
||||
* T47 — path of nested-arm indices leading to THIS arm in the
|
||||
* descriptor primitive tree. Used by the request-choice
|
||||
* suspension path to record where to resume after the player
|
||||
* answers. Top-level dispatcher entries seed `[]` (the arm IS
|
||||
* the root); nested children inherit `[...triggerPath, i]`
|
||||
* where `i` is the parent's loop index. The exact ENCODING is
|
||||
* private to this file + T46's resume mechanism — primitives
|
||||
* outside this module never inspect it.
|
||||
*
|
||||
* Existing callers can omit this parameter — the default `[]`
|
||||
* matches the historical behaviour for every non-suspending
|
||||
* arm. T46 will use the recorded path to walk back into the
|
||||
* descriptor tree at resume time.
|
||||
*/
|
||||
triggerPath: readonly number[] = [],
|
||||
): void {
|
||||
if (depth > 8) return; // hard runtime cap, mirrors validator
|
||||
// T15: cascade-depth guard. Distinct from `depth` (nested primitive
|
||||
|
|
@ -172,7 +193,8 @@ export function runPrimitives(
|
|||
// descendants (which run at `cascadeDepth + 1`).
|
||||
const pendingTriggers: PendingTrigger[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i]!;
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitive === undefined) continue;
|
||||
|
||||
|
|
@ -231,7 +253,60 @@ export function runPrimitives(
|
|||
// primitives never store these magic keys, so the walker is a
|
||||
// no-op for their params (returns a structurally-identical clone).
|
||||
const resolvedParams = resolveParams(node.params, ctx);
|
||||
primitive.apply(ctx, resolvedParams);
|
||||
|
||||
// T47: catch SuspendedExecution thrown by request-choice. The
|
||||
// primitive pushed a PendingChoice frame onto the GAME_ENTITY
|
||||
// stack and threw to short-circuit iteration. The frame's
|
||||
// `triggerPath` and `primitiveIndex` are placeholders — the
|
||||
// primitive itself can't know its index inside the iterating
|
||||
// loop. We mutate those two fields HERE (the dispatcher) by
|
||||
// popping, fixing, and re-pushing.
|
||||
//
|
||||
// After the fix-up we RETURN — siblings of the suspended
|
||||
// primitive must NOT execute (their continuation lives past
|
||||
// the resume that T46 will perform). The deferred-trigger
|
||||
// drain (T15) at the bottom of this function is also skipped
|
||||
// on suspension; T46 owns the resume drain semantics.
|
||||
//
|
||||
// The plan (T47 § "Suspension mechanism") prescribes `return`
|
||||
// rather than re-throw. This matches the V1 contract that
|
||||
// request-choice lives at the TOP of trigger arms, not deep
|
||||
// inside iteration orchestrators. Sibling tasks (validator)
|
||||
// can lock that invariant; for now the implementation matches
|
||||
// the plan literally so the resume helper (T46) sees a
|
||||
// correctly-scoped frame.
|
||||
//
|
||||
// Discrimination: `primitiveIndex === -1` is the SENTINEL set
|
||||
// by `request-choice.apply()`. A real index is always >= 0.
|
||||
// If we catch a SuspendedExecution where the top frame's
|
||||
// index is already populated (>= 0), some deeper dispatcher
|
||||
// already fixed it up — we leave the frame alone and just
|
||||
// stop iterating.
|
||||
try {
|
||||
primitive.apply(ctx, resolvedParams);
|
||||
} catch (e) {
|
||||
if (e instanceof SuspendedExecution) {
|
||||
const top = popPendingChoice(engine);
|
||||
if (
|
||||
top !== undefined &&
|
||||
top.choiceId === e.choice.choiceId &&
|
||||
top.primitiveIndex === -1
|
||||
) {
|
||||
pushPendingChoice(engine, {
|
||||
...top,
|
||||
triggerPath,
|
||||
primitiveIndex: i,
|
||||
});
|
||||
} else if (top !== undefined) {
|
||||
// Frame already populated by a deeper dispatcher. Push
|
||||
// it back unchanged so we don't drop the frame on its
|
||||
// way up.
|
||||
pushPendingChoice(engine, top);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (primitive.childPrimitives === undefined) continue;
|
||||
let children: readonly EffectPrimitiveNode[] = [];
|
||||
|
|
@ -251,6 +326,14 @@ export function runPrimitives(
|
|||
// doesn't jump artificially, and dry-mode propagates into
|
||||
// nested arms (a conditional inside a dry-probe must NOT
|
||||
// suddenly fire imperatives via its `then` branch).
|
||||
// T47: extend the triggerPath with this primitive's index
|
||||
// so a deeper request-choice records its location relative
|
||||
// to the descriptor root. The recursive call's own catch
|
||||
// swallows SuspendedExecution after fixing up the top
|
||||
// frame and returns; iteration here continues across
|
||||
// siblings normally (a nested suspension does NOT halt
|
||||
// outer iteration in V1 — that's a deferred validator
|
||||
// concern, see plan T47).
|
||||
runPrimitives(
|
||||
engine,
|
||||
pieceId,
|
||||
|
|
@ -260,6 +343,7 @@ export function runPrimitives(
|
|||
bindings,
|
||||
cascadeDepth,
|
||||
suppressTriggers,
|
||||
[...triggerPath, i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,41 @@ export type MarkerLifetimeValue =
|
|||
| { readonly kind: "moves"; readonly expiresAtMove: number }
|
||||
| { readonly kind: "one-shot" };
|
||||
|
||||
/**
|
||||
* T50 — per-game choice-timeout policy. Stored on `GAME_ENTITY` under
|
||||
* the `ChoiceTimeoutPolicy` attr. Locked verbatim by `decisions.md`
|
||||
* § Choice Timeout & Disconnect.
|
||||
*
|
||||
* - `timeout-with-default` — server arms a timer when a `request-choice`
|
||||
* suspends; on expiry it auto-selects the FIRST option and resumes
|
||||
* (T49). `seconds` is the per-choice budget; the wire schema enforces
|
||||
* `seconds >= 1` (server `protocol.ts` Zod refinement); UX guidance
|
||||
* is to keep the value reasonable (~30–120s) but the engine itself
|
||||
* only requires positivity so test fixtures can dial it down.
|
||||
* - `no-timeout` — no timer is armed; a pending choice waits indefinitely
|
||||
* until submitted or the player disconnects (T49 routes a disconnect
|
||||
* in this mode to a "paused" game state rather than a forfeit).
|
||||
*
|
||||
* Default value seeded by the engine when no policy is supplied:
|
||||
* `{ mode: "timeout-with-default", seconds: 60 }` — same default the
|
||||
* server uses when the wire payload omits the field. Centralising the
|
||||
* default on both sides means an old client that doesn't yet send the
|
||||
* field still gets a deterministic engine state.
|
||||
*/
|
||||
export type ChoiceTimeoutPolicyValue =
|
||||
| { readonly mode: "timeout-with-default"; readonly seconds: number }
|
||||
| { readonly mode: "no-timeout" };
|
||||
|
||||
/**
|
||||
* T50 — canonical default {@link ChoiceTimeoutPolicyValue} used when no
|
||||
* policy is supplied at engine construction. Mirrored by the server-side
|
||||
* Zod schema's `.default(...)` so both layers agree on the fallback.
|
||||
*/
|
||||
export const DEFAULT_CHOICE_TIMEOUT_POLICY: ChoiceTimeoutPolicyValue = {
|
||||
mode: "timeout-with-default",
|
||||
seconds: 60,
|
||||
};
|
||||
|
||||
export type PieceType = "pawn" | "knight" | "bishop" | "rook" | "queen" | "king";
|
||||
export type PieceColor = "white" | "black";
|
||||
export type MoveType = "capture" | "step" | "slide";
|
||||
|
|
@ -436,6 +471,124 @@ export interface ChessAttrMap {
|
|||
* map.
|
||||
*/
|
||||
MoveClassRestriction: MoveClassRestrictionValue | null;
|
||||
/**
|
||||
* T45 — LIFO stack of suspended choice frames. Stored on
|
||||
* `GAME_ENTITY` (one stack per game). Pushed when the
|
||||
* `request-choice` primitive (T47) suspends trigger execution,
|
||||
* peeked by the network layer (T44) when broadcasting the prompt
|
||||
* to clients, and popped by the `submit-choice` PlayerAction (T46)
|
||||
* when the player resolves the innermost choice.
|
||||
*
|
||||
* **LIFO ordering** is non-negotiable: nested choices push onto
|
||||
* the stack while an outer choice is still pending. The player
|
||||
* resolving the *innermost* choice pops their frame and the
|
||||
* next-outer continuation resumes — never the other way around.
|
||||
*
|
||||
* **Maximum depth = 8** (`MAX_CHOICE_DEPTH` in
|
||||
* `util/pending-choices.ts`). Exceeding this throws
|
||||
* `runtime.choice-depth-exceeded` (matches the cascade-depth
|
||||
* cap pattern from T15). The cap is data-dependent so it lives
|
||||
* at runtime push-time, not at validator time.
|
||||
*
|
||||
* Each entry is the full {@link PendingChoice} shape locked at T0
|
||||
* (`decisions.md` "Player Choice — Suspended Execution"). The
|
||||
* frame stores enough state — descriptor id, trigger path,
|
||||
* primitive index, captured bindings — to resume `runPrimitives`
|
||||
* exactly where it left off after the player's value is injected
|
||||
* under the request-choice's `bind` key.
|
||||
*
|
||||
* Helpers in `util/pending-choices.ts`:
|
||||
* - `pushPendingChoice(engine, choice)` — append + cap-check
|
||||
* - `popPendingChoice(engine)` — remove + return top
|
||||
* - `peekPendingChoice(engine)` — read top without mutating
|
||||
*
|
||||
* Serialization: `bindings` is a `ReadonlyMap` and Maps don't
|
||||
* round-trip through `JSON.stringify` natively. The util exports
|
||||
* `serializePendingChoice` / `deserializePendingChoice` which
|
||||
* convert the bindings Map↔`ReadonlyArray<[string, unknown]>` at
|
||||
* the save/load boundary; the in-memory Map shape is preserved
|
||||
* everywhere else for ergonomic reads.
|
||||
*/
|
||||
PendingChoices: readonly PendingChoice[];
|
||||
/**
|
||||
* T50 — per-game choice-timeout policy. Stored on `GAME_ENTITY`
|
||||
* (one fact per game). Seeded at engine construction from
|
||||
* `EngineOptions.choiceTimeout` (defaults to
|
||||
* {@link DEFAULT_CHOICE_TIMEOUT_POLICY}). Consumed at runtime by
|
||||
* T49's WS-layer timer + disconnect handler — the engine itself
|
||||
* never schedules timers; it just owns the policy fact so the
|
||||
* server has a single source of truth bound to the game session.
|
||||
*
|
||||
* Discriminated by `mode`:
|
||||
* - `"timeout-with-default"` — `seconds` is the per-choice budget
|
||||
* used by T49 to arm a timer; on expiry the WS layer auto-
|
||||
* submits the first valid option to the choice resolver.
|
||||
* - `"no-timeout"` — no timer is armed; pending choices wait
|
||||
* indefinitely. T49 routes a mid-choice disconnect to a paused
|
||||
* game state instead of a forfeit when this mode is active.
|
||||
*
|
||||
* Wire-side validation (server `protocol.ts`) constrains
|
||||
* `seconds >= 1` so a malformed `room.create` payload cannot land
|
||||
* a non-positive timeout on the engine. The TypeScript type
|
||||
* intentionally leaves the bound off — engine consumers (and unit
|
||||
* tests) treat the value as already-validated.
|
||||
*/
|
||||
ChoiceTimeoutPolicy: ChoiceTimeoutPolicyValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* T45 — single suspended choice frame in
|
||||
* {@link ChessAttrMap.PendingChoices}. Locked at T0
|
||||
* (`decisions.md` "Player Choice — Suspended Execution"); the
|
||||
* field set is byte-for-byte fixed and MUST NOT be extended without
|
||||
* a parallel decisions-doc amendment.
|
||||
*
|
||||
* Field semantics:
|
||||
* - `choiceId` — opaque correlation id assigned at request-choice
|
||||
* fire-time. The client echoes this back on `submit-choice` so
|
||||
* the dispatcher can match the response to the right frame
|
||||
* (matters when nested choices stack up).
|
||||
* - `descriptorId` — provenance: which descriptor authored the
|
||||
* trigger arm that suspended. Surfaced in error messages and
|
||||
* the WS request-choice broadcast (T44).
|
||||
* - `triggerPath` — path inside the trigger arm tree at which to
|
||||
* resume `runPrimitives` after the choice resolves. The
|
||||
* resume mechanism (T46) uses this + `primitiveIndex + 1` to
|
||||
* pick up exactly the next sibling.
|
||||
* - `primitiveIndex` — index of the suspended primitive within
|
||||
* the array at `triggerPath`. T46 resumes at index + 1.
|
||||
* - `bindings` — captured `PrimitiveApplyContext.bindings` at
|
||||
* suspension time. Restored into a fresh context on resume so
|
||||
* subsequent primitives see the same `ctx-attr` / `bind` map
|
||||
* they would have seen had no suspension occurred.
|
||||
* - `kind` — discriminator picked from the request-choice
|
||||
* primitive's `kind` field. Drives the client-side picker UI
|
||||
* (rps tap targets, square highlighting, etc.).
|
||||
* - `prompt` — human-readable question text shown alongside the
|
||||
* picker.
|
||||
* - `forPlayer` — which side may answer. `"both"` covers
|
||||
* "either-player can resolve" prompts (e.g. coin-flip
|
||||
* ceremony). The dispatcher rejects submit-choice from the
|
||||
* wrong side.
|
||||
* - `timeout` — optional duration in ms. The server starts a
|
||||
* timer on push; expiry triggers auto-resolve-to-first per
|
||||
* T0's v1/v2 fallback rule.
|
||||
* - `expiresAtTimestamp` — server-clock absolute ms target,
|
||||
* computed as `Date.now() + timeout` at push-time so client
|
||||
* reconnects can render a correct countdown without trusting
|
||||
* the local clock.
|
||||
*/
|
||||
export interface PendingChoice {
|
||||
readonly choiceId: string;
|
||||
readonly descriptorId: string;
|
||||
readonly triggerPath: readonly number[];
|
||||
readonly primitiveIndex: number;
|
||||
readonly bindings: ReadonlyMap<string, unknown>;
|
||||
readonly kind: "rps" | "piece" | "square" | "column" | "row";
|
||||
readonly prompt: string;
|
||||
readonly forPlayer: "white" | "black" | "both";
|
||||
readonly timeout?: number;
|
||||
readonly expiresAtTimestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -90,10 +90,164 @@ const SAMPLE_PARAMS: Record<PrimitiveKind, unknown> = {
|
|||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -2 } },
|
||||
],
|
||||
},
|
||||
"on-rule-activated": {
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
|
||||
],
|
||||
},
|
||||
"on-rule-expire": {
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
],
|
||||
},
|
||||
"on-piece-entered-marker": {
|
||||
markerKind: "mine",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
},
|
||||
"on-marker-expire": {
|
||||
markerKind: "frozen-square",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
],
|
||||
},
|
||||
"place-piece": { pieceType: "pawn", color: "white", square: 28 },
|
||||
"destroy-piece": { target: 28 },
|
||||
"move-piece": { target: 7, to: 28 },
|
||||
"convert-piece-type": { target: 7, pieceType: "queen" },
|
||||
"swap-pieces": { a: 7, b: 28 },
|
||||
"set-piece-attr": { target: 7, attr: "Hp", value: 5 },
|
||||
"set-moves-as": { target: 7, pieceType: "queen" },
|
||||
"set-moves-also-as": { target: 7, pieceType: "rook" },
|
||||
"cancel-capture": {},
|
||||
"spawn-marker": {
|
||||
markerKind: "mine",
|
||||
square: 28,
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
"spawn-marker-pair": {
|
||||
markerKind: "portal-end",
|
||||
squareA: 28,
|
||||
squareB: 35,
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
"destroy-marker": { target: 28 },
|
||||
"for-each-piece": {
|
||||
filter: { color: "white" },
|
||||
bind: "p",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: { target: 7, attr: "RangeBonus", value: 1 },
|
||||
},
|
||||
],
|
||||
},
|
||||
"for-each-adjacent": {
|
||||
target: "self",
|
||||
bind: "adj",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: { target: 7, attr: "Hp", value: 0 },
|
||||
},
|
||||
],
|
||||
},
|
||||
"for-each-square": {
|
||||
squares: [27, 28, 35, 36],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
square: 28,
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"for-each-marker": {
|
||||
filter: { markerKind: "mine" },
|
||||
bind: "m",
|
||||
then: [
|
||||
{ kind: "destroy-marker", params: { target: 28 } },
|
||||
],
|
||||
},
|
||||
"for-column": {
|
||||
columns: [0, 4, 7],
|
||||
bind: "c",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
square: 28,
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"for-row": {
|
||||
rows: [3, 4],
|
||||
bind: "r",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "death-square",
|
||||
square: 28,
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"random-pick": {
|
||||
from: [27, 28, 35, 36],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
square: 28,
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
conditional: {
|
||||
condition: { type: "attr-lt", attr: "Hp", value: 2 },
|
||||
then: [{ kind: "set-capture-flag", params: { flag: 2 } }],
|
||||
},
|
||||
"must-class": { class: "capture" },
|
||||
"with-probability": {
|
||||
p: 0.5,
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
else: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
},
|
||||
"block-by-piece-type": { pieceTypes: ["pawn", "knight"] },
|
||||
"pawn-pushes-pieces": { enabled: true },
|
||||
"request-choice": {
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
square: { $var: "sq" },
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -32,7 +32,35 @@ type ExtKind =
|
|||
| "on-check-received"
|
||||
| "on-check-delivered"
|
||||
| "on-moved-onto-square"
|
||||
| "on-captured";
|
||||
| "on-captured"
|
||||
| "on-rule-activated"
|
||||
| "on-rule-expire"
|
||||
| "on-piece-entered-marker"
|
||||
| "on-marker-expire"
|
||||
| "place-piece"
|
||||
| "destroy-piece"
|
||||
| "move-piece"
|
||||
| "swap-pieces"
|
||||
| "convert-piece-type"
|
||||
| "set-piece-attr"
|
||||
| "cancel-capture"
|
||||
| "spawn-marker"
|
||||
| "spawn-marker-pair"
|
||||
| "destroy-marker"
|
||||
| "for-each-piece"
|
||||
| "for-each-square"
|
||||
| "for-each-adjacent"
|
||||
| "for-each-marker"
|
||||
| "for-column"
|
||||
| "for-row"
|
||||
| "with-probability"
|
||||
| "random-pick"
|
||||
| "must-class"
|
||||
| "block-by-piece-type"
|
||||
| "set-moves-as"
|
||||
| "set-moves-also-as"
|
||||
| "pawn-pushes-pieces"
|
||||
| "request-choice";
|
||||
|
||||
function extNode(kind: ExtKind, params: unknown): EffectPrimitiveNode {
|
||||
// Structural cast: node shape is identical; kind union is the only
|
||||
|
|
@ -329,6 +357,419 @@ describe("narrateNodes — per-primitive narrators", () => {
|
|||
);
|
||||
});
|
||||
|
||||
// ── Wave-4 rule / marker triggers (4) ──────────────────────────
|
||||
|
||||
it("on-rule-activated renders rule-activation trigger", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("on-rule-activated", {
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe("When this rule activates: set RangeBonus to 1.");
|
||||
});
|
||||
|
||||
it("on-rule-expire renders rule-expiry trigger", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("on-rule-expire", {
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe("When this rule expires: set RangeBonus to 0.");
|
||||
});
|
||||
|
||||
it("on-piece-entered-marker mentions marker kind", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("on-piece-entered-marker", {
|
||||
markerKind: "mine",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"When a piece enters a mine marker: subtract 1 from Hp.",
|
||||
);
|
||||
});
|
||||
|
||||
it("on-marker-expire mentions marker kind", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("on-marker-expire", {
|
||||
markerKind: "ice",
|
||||
primitives: [
|
||||
{ kind: "seed-attribute", params: { attr: "RangeBonus", value: 0 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"When a ice marker expires: set RangeBonus to 0.",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Wave-5 board mutators (7) ───────────────────────────────────
|
||||
|
||||
it("place-piece names color, type, and square", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("place-piece", {
|
||||
pieceType: "queen",
|
||||
color: "white",
|
||||
square: 28,
|
||||
}),
|
||||
]),
|
||||
).toBe("place a white queen on e4");
|
||||
});
|
||||
|
||||
it("destroy-piece names target square", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("destroy-piece", { target: 12 })]),
|
||||
).toBe("destroy the piece at e2");
|
||||
});
|
||||
|
||||
it("move-piece names from and to squares", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("move-piece", { target: 12, to: 28 })]),
|
||||
).toBe("move the piece at e2 to e4");
|
||||
});
|
||||
|
||||
it("swap-pieces names both squares", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("swap-pieces", { a: 12, b: 28 })]),
|
||||
).toBe("swap the pieces at e2 and e4");
|
||||
});
|
||||
|
||||
it("convert-piece-type names target and new type", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("convert-piece-type", { target: 12, pieceType: "bishop" }),
|
||||
]),
|
||||
).toBe("convert the piece at e2 into a bishop");
|
||||
});
|
||||
|
||||
it("set-piece-attr renders attribute and value", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("set-piece-attr", {
|
||||
target: 12,
|
||||
attr: "Hp",
|
||||
value: 5,
|
||||
}),
|
||||
]),
|
||||
).toBe("set Hp on e2 to 5");
|
||||
});
|
||||
|
||||
it("set-piece-attr with turns lifetime appends suffix", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("set-piece-attr", {
|
||||
target: 12,
|
||||
attr: "Hp",
|
||||
value: 5,
|
||||
lifetime: { kind: "turns", count: 3 },
|
||||
}),
|
||||
]),
|
||||
).toBe("set Hp on e2 to 5 (for 3 turns)");
|
||||
});
|
||||
|
||||
it("cancel-capture renders fixed prose", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("cancel-capture", {})]),
|
||||
).toBe("cancel the capture in progress");
|
||||
});
|
||||
|
||||
// ── Wave-6 markers and loops (9) ────────────────────────────────
|
||||
|
||||
it("spawn-marker names kind, square, and lifetime", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("spawn-marker", {
|
||||
markerKind: "mine",
|
||||
square: 28,
|
||||
lifetime: { kind: "permanent" },
|
||||
}),
|
||||
]),
|
||||
).toBe("spawn a mine marker on e4 (permanent)");
|
||||
});
|
||||
|
||||
it("spawn-marker with one-shot lifetime and owner", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("spawn-marker", {
|
||||
markerKind: "trap",
|
||||
square: 35,
|
||||
lifetime: { kind: "one-shot" },
|
||||
owner: "white",
|
||||
}),
|
||||
]),
|
||||
).toBe("spawn a trap marker on d5 owned by white (one-shot)");
|
||||
});
|
||||
|
||||
it("spawn-marker-pair names both squares", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("spawn-marker-pair", {
|
||||
markerKind: "portal",
|
||||
squareA: 0,
|
||||
squareB: 63,
|
||||
lifetime: { kind: "permanent" },
|
||||
}),
|
||||
]),
|
||||
).toBe(
|
||||
"spawn a linked pair of portal markers on a1 and h8 (permanent)",
|
||||
);
|
||||
});
|
||||
|
||||
it("destroy-marker names the marker by id", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("destroy-marker", { target: 17 })]),
|
||||
).toBe("destroy marker #17");
|
||||
});
|
||||
|
||||
it("for-each-piece renders subject, bind, and body", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-each-piece", {
|
||||
filter: { color: "black", pieceType: "pawn" },
|
||||
bind: "p",
|
||||
then: [
|
||||
{
|
||||
kind: "set-piece-attr",
|
||||
params: { target: { $var: "p" }, attr: "Hp", value: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("For every black pawns (bind as p):");
|
||||
expect(out).toContain("set Hp on");
|
||||
});
|
||||
|
||||
it("for-each-piece with no filter says every piece", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-each-piece", {
|
||||
bind: "p",
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe("For every piece (bind as p): add 1 to Hp.");
|
||||
});
|
||||
|
||||
it("for-each-square names squares list", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-each-square", {
|
||||
squares: [28, 35],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"For each of squares e4, d5 (bind as sq): add 1 to Hp.",
|
||||
);
|
||||
});
|
||||
|
||||
it("for-each-adjacent names target and bind", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-each-adjacent", {
|
||||
target: "self",
|
||||
bind: "adj",
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"For each square adjacent to self (bind as adj): subtract 1 from Hp.",
|
||||
);
|
||||
});
|
||||
|
||||
it("for-each-adjacent with filter mentions filter clause", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-each-adjacent", {
|
||||
target: 28,
|
||||
bind: "adj",
|
||||
filter: { excludeKing: true, occupied: true },
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("adjacent to e4");
|
||||
expect(out).toContain("excluding kings");
|
||||
expect(out).toContain("occupied only");
|
||||
});
|
||||
|
||||
it("for-each-marker names filter and bind", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-each-marker", {
|
||||
filter: { markerKind: "ice", owner: "black" },
|
||||
bind: "m",
|
||||
then: [{ kind: "destroy-marker", params: { target: { $var: "m" } } }],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("For every ice owned by black marker (bind as m):");
|
||||
});
|
||||
|
||||
it("for-column names columns by file letter", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-column", {
|
||||
columns: [4],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"For each square in column e (bind as sq): add 1 to Hp.",
|
||||
);
|
||||
});
|
||||
|
||||
it("for-row names rows by 1-indexed rank", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("for-row", {
|
||||
rows: [0, 7],
|
||||
bind: "sq",
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"For each square in rows 1, 8 (bind as sq): add 1 to Hp.",
|
||||
);
|
||||
});
|
||||
|
||||
// ── Wave-7 control / movement / UI (8) ──────────────────────────
|
||||
|
||||
it("with-probability renders percent and branch", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("with-probability", {
|
||||
p: 0.25,
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe("With 25% probability: subtract 1 from Hp.");
|
||||
});
|
||||
|
||||
it("with-probability with else renders both branches", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("with-probability", {
|
||||
p: 0.5,
|
||||
then: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
else: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe(
|
||||
"With 50% probability: add 1 to Hp; otherwise: subtract 1 from Hp.",
|
||||
);
|
||||
});
|
||||
|
||||
it("random-pick names option count and bind", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("random-pick", {
|
||||
from: ["bishop", "knight", "rook"],
|
||||
bind: "pt",
|
||||
then: [
|
||||
{
|
||||
kind: "convert-piece-type",
|
||||
params: { target: "self", pieceType: { $var: "pt" } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("Pick one of 3 options at random (bind as pt):");
|
||||
});
|
||||
|
||||
it("must-class with capture renders capture-only prose", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("must-class", { class: "capture" })]),
|
||||
).toBe("force the next move to be a capture");
|
||||
});
|
||||
|
||||
it("must-class with move-to names the square", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("must-class", { class: "move-to", square: 28 }),
|
||||
]),
|
||||
).toBe("force the next move to land on e4");
|
||||
});
|
||||
|
||||
it("must-class with advance renders non-capture prose", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("must-class", { class: "advance" })]),
|
||||
).toBe("force the next move to be a non-capturing advance");
|
||||
});
|
||||
|
||||
it("block-by-piece-type names blocked types", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("block-by-piece-type", { pieceTypes: ["pawn", "knight"] }),
|
||||
]),
|
||||
).toBe("block pawn, knight from moving");
|
||||
});
|
||||
|
||||
it("set-moves-as names target and piece type", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("set-moves-as", { target: 12, pieceType: "queen" }),
|
||||
]),
|
||||
).toBe("make the piece at e2 move as a queen");
|
||||
});
|
||||
|
||||
it("set-moves-also-as names target and piece type", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("set-moves-also-as", { target: 7, pieceType: "knight" }),
|
||||
]),
|
||||
).toBe("let the piece at h1 also move as a knight");
|
||||
});
|
||||
|
||||
it("pawn-pushes-pieces enabled and disabled forms", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("pawn-pushes-pieces", { enabled: true })]),
|
||||
).toBe("allow pawns to push pieces ahead of them");
|
||||
expect(
|
||||
narrateNodes([extNode("pawn-pushes-pieces", { enabled: false })]),
|
||||
).toBe("disallow pawns from pushing pieces");
|
||||
});
|
||||
|
||||
it("request-choice names picker kind, prompt, player, and bind", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("request-choice", {
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
bind: "sq",
|
||||
then: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "mine",
|
||||
square: { $var: "sq" },
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("Ask white for a square choice");
|
||||
expect(out).toContain('"Pick a square"');
|
||||
expect(out).toContain("(bind as sq)");
|
||||
});
|
||||
|
||||
it("unknown primitive kind falls through to default", () => {
|
||||
// Intentionally unknown: narrator must render the kind verbatim.
|
||||
const node: EffectPrimitiveNode = {
|
||||
|
|
|
|||
|
|
@ -67,6 +67,103 @@ describe("BlockCard", () => {
|
|||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it("renders + Add primitive inside button when onAddChildClick provided and container expanded", () => {
|
||||
const node: EffectPrimitiveNode = {
|
||||
kind: "on-capture",
|
||||
params: { primitives: [] },
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockCard
|
||||
node={node}
|
||||
index={0}
|
||||
isSelected={false}
|
||||
isExpanded={true}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
onAddChildClick={() => {}}
|
||||
depth={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain('data-testid="block-add-child-on-capture"');
|
||||
expect(html).toContain("+ Add primitive inside");
|
||||
expect(html).toContain("border-dashed");
|
||||
});
|
||||
|
||||
it("renders + Add primitive inside button when container is selected (not expanded)", () => {
|
||||
const node: EffectPrimitiveNode = {
|
||||
kind: "on-turn-end",
|
||||
params: { primitives: [] },
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockCard
|
||||
node={node}
|
||||
index={0}
|
||||
isSelected={true}
|
||||
isExpanded={false}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
onAddChildClick={() => {}}
|
||||
depth={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).toContain('data-testid="block-add-child-on-turn-end"');
|
||||
});
|
||||
|
||||
it("omits + Add primitive inside button when onAddChildClick is not provided", () => {
|
||||
const node: EffectPrimitiveNode = {
|
||||
kind: "on-capture",
|
||||
params: { primitives: [] },
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockCard
|
||||
node={node}
|
||||
index={0}
|
||||
isSelected={true}
|
||||
isExpanded={true}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
depth={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-testid="block-add-child-on-capture"');
|
||||
expect(html).not.toContain("+ Add primitive inside");
|
||||
});
|
||||
|
||||
it("omits + Add primitive inside on non-container primitives (no childPrimitives)", () => {
|
||||
// seed-attribute is a State primitive — no childPrimitives in registry.
|
||||
// The hasChildren guard means the nested container never renders, so
|
||||
// even if onAddChildClick is passed it should not appear.
|
||||
const node: EffectPrimitiveNode = {
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "Hp", value: 1 },
|
||||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockCard
|
||||
node={node}
|
||||
index={0}
|
||||
isSelected={true}
|
||||
isExpanded={true}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
onAddChildClick={() => {}}
|
||||
depth={0}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(html).not.toContain('data-testid="block-add-child-seed-attribute"');
|
||||
});
|
||||
|
||||
it("depth clamps visually at 3", () => {
|
||||
const node: EffectPrimitiveNode = {
|
||||
kind: "add-direction",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ export interface BlockCardProps {
|
|||
* DragOverlay ghost render).
|
||||
*/
|
||||
dragHandleProps?: DragHandleProps;
|
||||
/**
|
||||
* When provided, a dashed "+ Add primitive inside" button is rendered
|
||||
* at the bottom of the nested-children container. Clicking it should
|
||||
* typically select this block so the palette's "Adding inside: X"
|
||||
* banner appears. Only meaningful when the primitive has
|
||||
* `childPrimitives` (i.e. is a trigger/container).
|
||||
*/
|
||||
onAddChildClick?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORIES: Record<string, PrimitiveKind[]> = {
|
||||
|
|
@ -88,6 +96,7 @@ export default function BlockCard({
|
|||
depth,
|
||||
childBlocks,
|
||||
dragHandleProps,
|
||||
onAddChildClick,
|
||||
}: BlockCardProps) {
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
const label = primitive?.label ?? node.kind;
|
||||
|
|
@ -269,10 +278,26 @@ export default function BlockCard({
|
|||
|
||||
{/* Nested children — shown when the block is expanded OR selected,
|
||||
so the user sees the inside of the trigger they just picked
|
||||
from the palette without needing an extra click. */}
|
||||
{(isExpanded || isSelected) && childBlocks && (
|
||||
from the palette without needing an extra click. Rendered
|
||||
whenever this is a container primitive (hasChildren) even if
|
||||
the child list is empty, so the "+ Add primitive inside"
|
||||
affordance is visible for empty triggers. */}
|
||||
{(isExpanded || isSelected) && hasChildren && (
|
||||
<div className="p-2 border-t border-black/5 bg-black/5 rounded-b-lg">
|
||||
{childBlocks}
|
||||
{onAddChildClick && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`block-add-child-${node.kind}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddChildClick();
|
||||
}}
|
||||
className="mt-2 w-full rounded-md border border-dashed border-violet-300 px-3 py-2 text-xs font-semibold text-violet-700 hover:bg-violet-50 hover:border-violet-400 transition-colors focus:outline-none focus:ring-2 focus:ring-violet-400"
|
||||
>
|
||||
+ Add primitive inside
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ describe("BlockList", () => {
|
|||
const html = renderToStaticMarkup(
|
||||
<BlockList
|
||||
nodes={[]}
|
||||
selectedIndex={null}
|
||||
expandedIndices={new Set()}
|
||||
selectedPath={[]}
|
||||
expandedPaths={new Set()}
|
||||
basePath={[]}
|
||||
onReorder={() => {}}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
|
|
@ -32,8 +33,9 @@ describe("BlockList", () => {
|
|||
const html = renderToStaticMarkup(
|
||||
<BlockList
|
||||
nodes={nodes}
|
||||
selectedIndex={0}
|
||||
expandedIndices={new Set()}
|
||||
selectedPath={[0]}
|
||||
expandedPaths={new Set()}
|
||||
basePath={[]}
|
||||
onReorder={() => {}}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
|
|
@ -47,6 +49,70 @@ describe("BlockList", () => {
|
|||
expect(html).toContain('class="flex flex-col gap-2"');
|
||||
});
|
||||
|
||||
it("renders nested child block when container is expanded", () => {
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "on-capture",
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attribute: "hp", amount: 1 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockList
|
||||
nodes={nodes}
|
||||
selectedPath={[]}
|
||||
expandedPaths={new Set(['0'])}
|
||||
basePath={[]}
|
||||
onReorder={() => {}}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Parent rendered
|
||||
expect(html).toContain('data-testid="block-card-on-capture"');
|
||||
// Nested child rendered because expandedPaths has key '0'
|
||||
expect(html).toContain('data-testid="block-card-add-to-attribute"');
|
||||
// Add-child affordance rendered on expanded container
|
||||
expect(html).toContain('data-testid="block-add-child-on-capture"');
|
||||
});
|
||||
|
||||
it("marks nested child as selected when selectedPath points to it", () => {
|
||||
const nodes: EffectPrimitiveNode[] = [
|
||||
{
|
||||
kind: "on-capture",
|
||||
params: {
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attribute: "hp", amount: 1 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockList
|
||||
nodes={nodes}
|
||||
selectedPath={[0, 0]}
|
||||
expandedPaths={new Set(['0'])}
|
||||
basePath={[]}
|
||||
onReorder={() => {}}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// The selected child should get the blue-500 (State category)
|
||||
// "selected" border class — verifies selection propagates into
|
||||
// nested lists instead of being nulled out.
|
||||
expect(html).toContain("border-blue-500");
|
||||
});
|
||||
|
||||
it("clicking a block calls onSelect with correct index", () => {
|
||||
// SSR doesn't fire events, and no @testing-library/react installed, so we rely on static representation test
|
||||
// that verifies the BlockCards are rendered. The actual interaction is tested in E2E.
|
||||
|
|
@ -59,19 +125,20 @@ describe("BlockList", () => {
|
|||
const nodes: EffectPrimitiveNode[] = [
|
||||
{ kind: "seed-attribute", params: { attr: "Hp", value: 1 } },
|
||||
];
|
||||
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<BlockList
|
||||
nodes={nodes}
|
||||
selectedIndex={null}
|
||||
expandedIndices={new Set()}
|
||||
selectedPath={[]}
|
||||
expandedPaths={new Set()}
|
||||
basePath={[]}
|
||||
onReorder={() => {}}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
// Check for standard dnd-kit sortable attributes on the wrapping element
|
||||
expect(html).toContain('aria-roledescription="sortable"');
|
||||
expect(html).toContain('role="button"');
|
||||
|
|
|
|||
|
|
@ -23,24 +23,33 @@ import type { EffectPrimitiveNode } from '../../modifiers/primitives/types.js';
|
|||
import { PRIMITIVE_REGISTRY } from '../../modifiers/primitives/registry.js';
|
||||
import BlockCard from './BlockCard.js';
|
||||
|
||||
/**
|
||||
* Path identifying a primitive inside the descriptor's nested tree.
|
||||
* `[]` = no selection; `[0]` = top-level primitive 0; `[0, 2]` = child 2
|
||||
* of top-level 0 (via `params.primitives`). Traversal only walks the
|
||||
* `primitives` key — the `then`/`else` arrays of `conditional` are
|
||||
* intentionally out-of-scope for selection at this time.
|
||||
*/
|
||||
export type SelectionPath = readonly number[];
|
||||
|
||||
function pathsEqual(a: SelectionPath, b: SelectionPath): boolean {
|
||||
return a.length === b.length && a.every((v, i) => v === b[i]);
|
||||
}
|
||||
|
||||
export interface BlockListProps {
|
||||
nodes: readonly EffectPrimitiveNode[];
|
||||
selectedIndex: number | null;
|
||||
expandedIndices: ReadonlySet<number>;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
onSelect: (index: number) => void;
|
||||
onToggleExpand: (index: number) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onParamsChange?: (index: number, params: unknown) => void;
|
||||
onNestedReorder?: (parentIndex: number, fromChildIndex: number, toChildIndex: number) => void;
|
||||
/**
|
||||
* Called when the user clicks × on a child block inside a trigger.
|
||||
* `parentIndex` is the child's parent in THIS list; `childIndex` is
|
||||
* the child's position within `parent.params.primitives`. When
|
||||
* omitted, the × button on nested blocks is a no-op (present for
|
||||
* backward compat with existing callers).
|
||||
*/
|
||||
onNestedRemove?: (parentIndex: number, childIndex: number) => void;
|
||||
/** Full selection path in the root descriptor. `[]` = nothing selected. */
|
||||
selectedPath: SelectionPath;
|
||||
/** Set of expanded paths encoded via `path.join('.')`. */
|
||||
expandedPaths: ReadonlySet<string>;
|
||||
/** Prefix path from root to THIS list. `[]` for the top-level list. */
|
||||
basePath?: SelectionPath;
|
||||
/** Reorder siblings under `parentPath`. */
|
||||
onReorder: (parentPath: SelectionPath, fromIndex: number, toIndex: number) => void;
|
||||
onSelect: (path: SelectionPath) => void;
|
||||
onToggleExpand: (path: SelectionPath) => void;
|
||||
onRemove: (path: SelectionPath) => void;
|
||||
onParamsChange?: (path: SelectionPath, params: unknown) => void;
|
||||
depth?: number;
|
||||
}
|
||||
|
||||
|
|
@ -54,6 +63,7 @@ interface SortableBlockItemProps {
|
|||
onToggleExpand: () => void;
|
||||
onRemove: () => void;
|
||||
onParamsChange?: (params: unknown) => void;
|
||||
onAddChildClick?: () => void;
|
||||
depth: number;
|
||||
childBlocks?: React.ReactNode;
|
||||
}
|
||||
|
|
@ -95,6 +105,7 @@ function SortableBlockItem(props: SortableBlockItemProps) {
|
|||
onToggleExpand={props.onToggleExpand}
|
||||
onRemove={props.onRemove}
|
||||
{...(props.onParamsChange ? { onParamsChange: props.onParamsChange } : {})}
|
||||
{...(props.onAddChildClick ? { onAddChildClick: props.onAddChildClick } : {})}
|
||||
depth={props.depth}
|
||||
childBlocks={props.childBlocks}
|
||||
dragHandleProps={dragHandleProps}
|
||||
|
|
@ -105,15 +116,14 @@ function SortableBlockItem(props: SortableBlockItemProps) {
|
|||
|
||||
export function BlockList({
|
||||
nodes,
|
||||
selectedIndex,
|
||||
expandedIndices,
|
||||
selectedPath,
|
||||
expandedPaths,
|
||||
basePath = [],
|
||||
onReorder,
|
||||
onSelect,
|
||||
onToggleExpand,
|
||||
onRemove,
|
||||
onParamsChange,
|
||||
onNestedReorder,
|
||||
onNestedRemove,
|
||||
depth = 0,
|
||||
}: BlockListProps) {
|
||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||
|
|
@ -125,8 +135,15 @@ export function BlockList({
|
|||
})
|
||||
);
|
||||
|
||||
const nodeIds = React.useMemo(() => nodes.map((_, i) => `block-${depth}-${i}`), [nodes, depth]);
|
||||
const activeNode = activeId !== null
|
||||
// Include basePath in the id so nested SortableContexts don't collide
|
||||
// with the top-level one when the same index appears at multiple
|
||||
// depths.
|
||||
const basePathKey = basePath.join('.');
|
||||
const nodeIds = React.useMemo(
|
||||
() => nodes.map((_, i) => `block-${basePathKey}-${depth}-${i}`),
|
||||
[nodes, depth, basePathKey]
|
||||
);
|
||||
const activeNode = activeId !== null
|
||||
? nodes[nodeIds.indexOf(activeId as string)]
|
||||
: null;
|
||||
|
||||
|
|
@ -177,7 +194,7 @@ export function BlockList({
|
|||
const oldIndex = nodeIds.indexOf(active.id as string);
|
||||
const newIndex = nodeIds.indexOf(over.id as string);
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
onReorder(oldIndex, newIndex);
|
||||
onReorder(basePath, oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -199,42 +216,51 @@ export function BlockList({
|
|||
<div className="flex flex-col gap-2">
|
||||
{nodes.map((node, index) => {
|
||||
const id = nodeIds[index];
|
||||
const isExpanded = expandedIndices.has(index);
|
||||
const thisPath: SelectionPath = [...basePath, index];
|
||||
const thisPathKey = thisPath.join('.');
|
||||
const isSelected = pathsEqual(selectedPath, thisPath);
|
||||
const isExpanded = expandedPaths.has(thisPathKey);
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
const hasChildren = primitive?.childPrimitives !== undefined;
|
||||
|
||||
let childBlocks: React.ReactNode = null;
|
||||
if (hasChildren && isExpanded && typeof node.params === 'object' && node.params !== null && 'primitives' in node.params && Array.isArray((node.params as Record<string, unknown>).primitives)) {
|
||||
const childNodes = (node.params as Record<string, unknown>).primitives as EffectPrimitiveNode[];
|
||||
// Render nested block list. Selection and expansion
|
||||
// are intentionally scoped to the top level for now —
|
||||
// multi-level selection would need a richer path-based
|
||||
// selector than the current flat number. Removal of
|
||||
// individual children IS supported via onNestedRemove.
|
||||
childBlocks = (
|
||||
<BlockList
|
||||
if (
|
||||
hasChildren &&
|
||||
(isExpanded || isSelected) &&
|
||||
typeof node.params === 'object' &&
|
||||
node.params !== null &&
|
||||
'primitives' in node.params &&
|
||||
Array.isArray((node.params as Record<string, unknown>).primitives)
|
||||
) {
|
||||
const childNodes = (node.params as Record<string, unknown>).primitives as EffectPrimitiveNode[];
|
||||
// Recursive render — real callbacks (no no-ops). Each
|
||||
// child computes its own selection/expansion via its
|
||||
// path = [...basePath, index, childIndex].
|
||||
if (childNodes.length > 0) {
|
||||
childBlocks = (
|
||||
<BlockList
|
||||
nodes={childNodes}
|
||||
selectedIndex={null}
|
||||
expandedIndices={new Set()}
|
||||
onReorder={(fromIdx, toIdx) => {
|
||||
if (onNestedReorder) {
|
||||
onNestedReorder(index, fromIdx, toIdx);
|
||||
}
|
||||
}}
|
||||
onSelect={() => {}}
|
||||
onToggleExpand={() => {}}
|
||||
onRemove={(childIdx) => {
|
||||
if (onNestedRemove) {
|
||||
onNestedRemove(index, childIdx);
|
||||
}
|
||||
}}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
basePath={thisPath}
|
||||
onReorder={onReorder}
|
||||
onSelect={onSelect}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onRemove={onRemove}
|
||||
{...(onParamsChange ? { onParamsChange } : {})}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
);
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const paramsChangeProp = onParamsChange
|
||||
? { onParamsChange: (params: unknown) => onParamsChange(index, params) }
|
||||
? { onParamsChange: (params: unknown) => onParamsChange(thisPath, params) }
|
||||
: {};
|
||||
// Only wire onAddChildClick for container primitives — it's
|
||||
// the signal that the add-affordance should appear.
|
||||
const addChildProp = hasChildren
|
||||
? { onAddChildClick: () => onSelect(thisPath) }
|
||||
: {};
|
||||
return (
|
||||
<SortableBlockItem
|
||||
|
|
@ -242,12 +268,13 @@ export function BlockList({
|
|||
id={id || `block-${index}`}
|
||||
node={node}
|
||||
index={index}
|
||||
isSelected={selectedIndex === index}
|
||||
isSelected={isSelected}
|
||||
isExpanded={isExpanded}
|
||||
onSelect={() => onSelect(index)}
|
||||
onToggleExpand={() => onToggleExpand(index)}
|
||||
onRemove={() => onRemove(index)}
|
||||
onSelect={() => onSelect(thisPath)}
|
||||
onToggleExpand={() => onToggleExpand(thisPath)}
|
||||
onRemove={() => onRemove(thisPath)}
|
||||
{...paramsChangeProp}
|
||||
{...addChildProp}
|
||||
depth={depth}
|
||||
childBlocks={childBlocks}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -100,21 +100,23 @@ describe('VisualBuilderPane', () => {
|
|||
};
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<VisualBuilderPane
|
||||
descriptor={descriptor}
|
||||
onChange={() => {}}
|
||||
validationResult={validResult}
|
||||
<VisualBuilderPane
|
||||
descriptor={descriptor}
|
||||
onChange={() => {}}
|
||||
validationResult={validResult}
|
||||
/>
|
||||
);
|
||||
|
||||
// Initial render: nothing selected, nothing expanded → only the
|
||||
// top-level on-capture block renders. The nested child + add-child
|
||||
// affordance only appear after the user clicks the parent (which
|
||||
// we can't simulate with renderToStaticMarkup). Nested-render
|
||||
// assertions for expanded/selected state live in BlockList.test.tsx
|
||||
// where we can seed `expandedPaths` directly.
|
||||
expect(html).toContain('data-testid="block-card-on-capture"');
|
||||
// We expect the nested child card to be present, but since our mock DOM render
|
||||
// doesn't click "expand", we need to check if it's there based on the
|
||||
// actual rendering logic. Ah, BlockCard renders childBlocks if isExpanded.
|
||||
// By default, expandedIndices is empty, so we won't see the child block in static markup
|
||||
// unless we mock it or the component allows initial expansion state.
|
||||
// Given the component API doesn't support initialExpanded props, we'll verify the
|
||||
// container/props or we can just render it using a testing library if we need interactive.
|
||||
// For this basic static check, we'll confirm the parent block is present.
|
||||
expect(html).not.toContain('data-testid="block-card-add-to-attribute"');
|
||||
expect(html).not.toContain('data-testid="block-add-child-on-capture"');
|
||||
// Palette banner should be hidden initially (nothing selected).
|
||||
expect(html).not.toContain('data-testid="palette-add-target-banner"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type { CustomModifierDescriptor } from '../../modifiers/custom/types.js';
|
|||
import type { ValidationResult } from '../../modifiers/custom/validate.js';
|
||||
import type { EffectPrimitiveNode, PrimitiveKind } from '../../modifiers/primitives/types.js';
|
||||
import { PRIMITIVE_REGISTRY } from '../../modifiers/primitives/registry.js';
|
||||
import { BlockList } from './BlockList.js';
|
||||
import { BlockList, type SelectionPath } from './BlockList.js';
|
||||
import { PreviewPane } from './preview/PreviewPane.js';
|
||||
|
||||
export interface VisualBuilderPaneProps {
|
||||
|
|
@ -82,35 +82,169 @@ function getCategoryForKind(kind: PrimitiveKind): string {
|
|||
return 'Trigger';
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Path helpers
|
||||
//
|
||||
// A SelectionPath is `readonly number[]`. Walking only traverses the
|
||||
// `primitives` key inside `node.params` (which is where triggers,
|
||||
// `add-aura`, and top-level containers nest their children). The
|
||||
// `conditional` primitive's separate `then`/`else` arrays are
|
||||
// intentionally out of scope for this refactor — selection / edit of
|
||||
// those still requires form mode, as it did before.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function getChildrenOf(node: EffectPrimitiveNode): readonly EffectPrimitiveNode[] | null {
|
||||
const params = node.params;
|
||||
if (
|
||||
typeof params !== 'object' ||
|
||||
params === null ||
|
||||
!('primitives' in params) ||
|
||||
!Array.isArray((params as Record<string, unknown>).primitives)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (params as Record<string, unknown>).primitives as EffectPrimitiveNode[];
|
||||
}
|
||||
|
||||
function withChildren(
|
||||
node: EffectPrimitiveNode,
|
||||
children: readonly EffectPrimitiveNode[]
|
||||
): EffectPrimitiveNode {
|
||||
const params = node.params;
|
||||
const base =
|
||||
typeof params === 'object' && params !== null
|
||||
? (params as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...node,
|
||||
params: { ...base, primitives: children },
|
||||
};
|
||||
}
|
||||
|
||||
function getNodeAtPath(
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
path: SelectionPath
|
||||
): EffectPrimitiveNode | null {
|
||||
if (path.length === 0) return null;
|
||||
let current: readonly EffectPrimitiveNode[] = primitives;
|
||||
let node: EffectPrimitiveNode | undefined;
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const idx = path[i];
|
||||
if (idx === undefined) return null;
|
||||
node = current[idx];
|
||||
if (!node) return null;
|
||||
if (i < path.length - 1) {
|
||||
const children = getChildrenOf(node);
|
||||
if (!children) return null;
|
||||
current = children;
|
||||
}
|
||||
}
|
||||
return node ?? null;
|
||||
}
|
||||
|
||||
function updateAtPath(
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
path: SelectionPath,
|
||||
updater: (node: EffectPrimitiveNode) => EffectPrimitiveNode
|
||||
): readonly EffectPrimitiveNode[] {
|
||||
if (path.length === 0) return primitives;
|
||||
const [head, ...rest] = path;
|
||||
if (head === undefined || head < 0 || head >= primitives.length) return primitives;
|
||||
const target = primitives[head];
|
||||
if (!target) return primitives;
|
||||
const next = [...primitives];
|
||||
if (rest.length === 0) {
|
||||
next[head] = updater(target);
|
||||
} else {
|
||||
const children = getChildrenOf(target);
|
||||
if (!children) return primitives;
|
||||
const updatedChildren = updateAtPath(children, rest, updater);
|
||||
next[head] = withChildren(target, updatedChildren);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeAtPath(
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
path: SelectionPath
|
||||
): readonly EffectPrimitiveNode[] {
|
||||
if (path.length === 0) return primitives;
|
||||
if (path.length === 1) {
|
||||
const idx = path[0];
|
||||
if (idx === undefined || idx < 0 || idx >= primitives.length) return primitives;
|
||||
const next = [...primitives];
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
}
|
||||
const [head, ...rest] = path;
|
||||
if (head === undefined || head < 0 || head >= primitives.length) return primitives;
|
||||
const target = primitives[head];
|
||||
if (!target) return primitives;
|
||||
const children = getChildrenOf(target);
|
||||
if (!children) return primitives;
|
||||
const updatedChildren = removeAtPath(children, rest);
|
||||
const next = [...primitives];
|
||||
next[head] = withChildren(target, updatedChildren);
|
||||
return next;
|
||||
}
|
||||
|
||||
function appendChildAtPath(
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
parentPath: SelectionPath,
|
||||
newNode: EffectPrimitiveNode
|
||||
): readonly EffectPrimitiveNode[] {
|
||||
if (parentPath.length === 0) {
|
||||
return [...primitives, newNode];
|
||||
}
|
||||
return updateAtPath(primitives, parentPath, (parent) => {
|
||||
const existing = getChildrenOf(parent) ?? [];
|
||||
return withChildren(parent, [...existing, newNode]);
|
||||
});
|
||||
}
|
||||
|
||||
function reorderAtPath(
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
parentPath: SelectionPath,
|
||||
from: number,
|
||||
to: number
|
||||
): readonly EffectPrimitiveNode[] {
|
||||
if (parentPath.length === 0) {
|
||||
return arrayMove([...primitives], from, to);
|
||||
}
|
||||
return updateAtPath(primitives, parentPath, (parent) => {
|
||||
const existing = getChildrenOf(parent) ?? [];
|
||||
return withChildren(parent, arrayMove([...existing], from, to));
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns true if `child` is `parent` or a descendant of `parent`. */
|
||||
function pathStartsWith(child: SelectionPath, parent: SelectionPath): boolean {
|
||||
if (child.length < parent.length) return false;
|
||||
for (let i = 0; i < parent.length; i++) {
|
||||
if (child[i] !== parent[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function VisualBuilderPane({ descriptor, onChange, validationResult }: VisualBuilderPaneProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(new Set());
|
||||
const [selectedPath, setSelectedPath] = useState<SelectionPath>([]);
|
||||
const [expandedPaths, setExpandedPaths] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
/**
|
||||
* If the user has a trigger/container primitive selected (e.g. the
|
||||
* user just clicked "On Turn End"), a new primitive from the palette
|
||||
* lands INSIDE that trigger's `params.primitives` — not at the top
|
||||
* level. Otherwise it's appended to the descriptor root.
|
||||
*
|
||||
* Determined by checking whether the selected primitive's registry
|
||||
* entry exposes `childPrimitives` (all triggers + conditional do).
|
||||
* If the user has a trigger/container primitive selected (at any
|
||||
* depth), a new primitive from the palette lands INSIDE that
|
||||
* container's `params.primitives` — not at the top level. Otherwise
|
||||
* it's appended to the descriptor root.
|
||||
*/
|
||||
const addTargetInfo = (() => {
|
||||
if (selectedIndex === null) return null;
|
||||
const parent = descriptor.primitives[selectedIndex];
|
||||
if (selectedPath.length === 0) return null;
|
||||
const parent = getNodeAtPath(descriptor.primitives, selectedPath);
|
||||
if (!parent) return null;
|
||||
const registryEntry = PRIMITIVE_REGISTRY.get(parent.kind);
|
||||
if (registryEntry?.childPrimitives === undefined) return null;
|
||||
if (
|
||||
typeof parent.params !== 'object' ||
|
||||
parent.params === null ||
|
||||
!('primitives' in parent.params) ||
|
||||
!Array.isArray((parent.params as Record<string, unknown>).primitives)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (getChildrenOf(parent) === null) return null;
|
||||
return {
|
||||
parentIndex: selectedIndex,
|
||||
parentPath: selectedPath,
|
||||
parentLabel: registryEntry.label ?? parent.kind,
|
||||
};
|
||||
})();
|
||||
|
|
@ -126,28 +260,14 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
|||
|
||||
// Nested add: append to the selected container's params.primitives.
|
||||
if (addTargetInfo !== null) {
|
||||
const { parentIndex } = addTargetInfo;
|
||||
const parent = descriptor.primitives[parentIndex];
|
||||
if (!parent) return;
|
||||
const parentParams = parent.params as Record<string, unknown>;
|
||||
const existingChildren =
|
||||
(parentParams.primitives as EffectPrimitiveNode[] | undefined) ?? [];
|
||||
|
||||
const newPrimitives = [...descriptor.primitives];
|
||||
newPrimitives[parentIndex] = {
|
||||
...parent,
|
||||
params: {
|
||||
...parentParams,
|
||||
primitives: [...existingChildren, newNode],
|
||||
},
|
||||
};
|
||||
|
||||
const { parentPath } = addTargetInfo;
|
||||
const newPrimitives = appendChildAtPath(descriptor.primitives, parentPath, newNode);
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
|
||||
// Auto-expand the parent so the new child is visible immediately.
|
||||
setExpandedIndices((prev) => {
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(parentIndex);
|
||||
next.add(parentPath.join('.'));
|
||||
return next;
|
||||
});
|
||||
// Keep selection on the parent so successive palette clicks keep
|
||||
|
|
@ -158,121 +278,91 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
|||
// Top-level add.
|
||||
const newPrimitives = [...descriptor.primitives, newNode];
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
setSelectedIndex(newPrimitives.length - 1);
|
||||
setSelectedPath([newPrimitives.length - 1]);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newPrimitives = [...descriptor.primitives];
|
||||
newPrimitives.splice(index, 1);
|
||||
const handleRemove = (path: SelectionPath) => {
|
||||
const newPrimitives = removeAtPath(descriptor.primitives, path);
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
|
||||
if (selectedIndex === index) {
|
||||
setSelectedIndex(null);
|
||||
} else if (selectedIndex !== null && selectedIndex > index) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
|
||||
// Drop selection if the removed node contained it.
|
||||
if (pathStartsWith(selectedPath, path)) {
|
||||
setSelectedPath([]);
|
||||
}
|
||||
|
||||
const newExpanded = new Set(expandedIndices);
|
||||
newExpanded.delete(index);
|
||||
// Shift indices down for expanded set
|
||||
const finalExpanded = new Set<number>();
|
||||
for (const idx of newExpanded) {
|
||||
if (idx > index) finalExpanded.add(idx - 1);
|
||||
else finalExpanded.add(idx);
|
||||
}
|
||||
setExpandedIndices(finalExpanded);
|
||||
// Drop any expanded paths under the removed subtree. We don't try
|
||||
// to shift sibling indices because the set is cheap to rebuild and
|
||||
// any stale entries would just be silently ignored at render time.
|
||||
setExpandedPaths((prev) => {
|
||||
const removedKey = path.join('.');
|
||||
const next = new Set<string>();
|
||||
for (const key of prev) {
|
||||
if (key === removedKey) continue;
|
||||
if (key.startsWith(`${removedKey}.`)) continue;
|
||||
next.add(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReorder = (from: number, to: number) => {
|
||||
const newPrimitives = arrayMove([...descriptor.primitives], from, to);
|
||||
const handleReorder = (parentPath: SelectionPath, from: number, to: number) => {
|
||||
const newPrimitives = reorderAtPath(descriptor.primitives, parentPath, from, to);
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
|
||||
if (selectedIndex === from) {
|
||||
setSelectedIndex(to);
|
||||
} else if (selectedIndex !== null) {
|
||||
if (from < selectedIndex && to >= selectedIndex) {
|
||||
setSelectedIndex(selectedIndex - 1);
|
||||
} else if (from > selectedIndex && to <= selectedIndex) {
|
||||
setSelectedIndex(selectedIndex + 1);
|
||||
// Adjust selection if it pointed into the reordered list.
|
||||
if (pathStartsWith(selectedPath, parentPath) && selectedPath.length > parentPath.length) {
|
||||
const idx = selectedPath[parentPath.length];
|
||||
if (idx !== undefined) {
|
||||
let newIdx = idx;
|
||||
if (idx === from) newIdx = to;
|
||||
else if (from < idx && to >= idx) newIdx = idx - 1;
|
||||
else if (from > idx && to <= idx) newIdx = idx + 1;
|
||||
if (newIdx !== idx) {
|
||||
setSelectedPath([...parentPath, newIdx, ...selectedPath.slice(parentPath.length + 1)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// NOTE: we don't try to remap expandedPaths keys through the
|
||||
// reorder. Stale keys just render as "not expanded" — the user can
|
||||
// click to re-expand. Keeps this simple until we have tests that
|
||||
// exercise reorder + expansion together.
|
||||
};
|
||||
|
||||
const newExpanded = new Set<number>();
|
||||
for (const idx of expandedIndices) {
|
||||
if (idx === from) {
|
||||
newExpanded.add(to);
|
||||
} else if (from < idx && to >= idx) {
|
||||
newExpanded.add(idx - 1);
|
||||
} else if (from > idx && to <= idx) {
|
||||
newExpanded.add(idx + 1);
|
||||
} else {
|
||||
newExpanded.add(idx);
|
||||
const handleParamsChange = (path: SelectionPath, params: unknown) => {
|
||||
if (path.length === 0) return;
|
||||
const newPrimitives = updateAtPath(descriptor.primitives, path, (node) => ({
|
||||
...node,
|
||||
params,
|
||||
}));
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
};
|
||||
|
||||
const handleToggleExpand = (path: SelectionPath) => {
|
||||
const key = path.join('.');
|
||||
setExpandedPaths((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelect = (path: SelectionPath) => {
|
||||
setSelectedPath(path);
|
||||
// Auto-expand the selected container so its children (and the
|
||||
// "+ Add primitive inside" affordance) are immediately visible.
|
||||
if (path.length > 0) {
|
||||
const node = getNodeAtPath(descriptor.primitives, path);
|
||||
if (node && PRIMITIVE_REGISTRY.get(node.kind)?.childPrimitives !== undefined) {
|
||||
setExpandedPaths((prev) => {
|
||||
const key = path.join('.');
|
||||
if (prev.has(key)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.add(key);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
setExpandedIndices(newExpanded);
|
||||
};
|
||||
|
||||
const handleNestedReorder = (parentIdx: number, from: number, to: number) => {
|
||||
const parent = descriptor.primitives[parentIdx];
|
||||
if (!parent || typeof parent.params !== 'object' || parent.params === null || !('primitives' in parent.params)) return;
|
||||
|
||||
const childPrimitives = (parent.params as Record<string, unknown>).primitives as EffectPrimitiveNode[];
|
||||
const reordered = arrayMove([...childPrimitives], from, to);
|
||||
|
||||
const newPrimitives = [...descriptor.primitives];
|
||||
newPrimitives[parentIdx] = {
|
||||
...parent,
|
||||
params: {
|
||||
...parent.params,
|
||||
primitives: reordered
|
||||
}
|
||||
};
|
||||
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
};
|
||||
|
||||
const handleNestedRemove = (parentIdx: number, childIdx: number) => {
|
||||
const parent = descriptor.primitives[parentIdx];
|
||||
if (
|
||||
!parent ||
|
||||
typeof parent.params !== 'object' ||
|
||||
parent.params === null ||
|
||||
!('primitives' in parent.params)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const childPrimitives =
|
||||
((parent.params as Record<string, unknown>).primitives as EffectPrimitiveNode[] | undefined) ?? [];
|
||||
const filtered = childPrimitives.filter((_, i) => i !== childIdx);
|
||||
|
||||
const newPrimitives = [...descriptor.primitives];
|
||||
newPrimitives[parentIdx] = {
|
||||
...parent,
|
||||
params: {
|
||||
...(parent.params as Record<string, unknown>),
|
||||
primitives: filtered,
|
||||
},
|
||||
};
|
||||
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
};
|
||||
|
||||
const handleParamsChange = (index: number, params: unknown) => {
|
||||
const target = descriptor.primitives[index];
|
||||
if (!target) return;
|
||||
const newPrimitives = [...descriptor.primitives];
|
||||
newPrimitives[index] = { ...target, params };
|
||||
onChange({ ...descriptor, primitives: newPrimitives });
|
||||
};
|
||||
|
||||
const handleToggleExpand = (index: number) => {
|
||||
const newExpanded = new Set(expandedIndices);
|
||||
if (newExpanded.has(index)) {
|
||||
newExpanded.delete(index);
|
||||
} else {
|
||||
newExpanded.add(index);
|
||||
}
|
||||
setExpandedIndices(newExpanded);
|
||||
};
|
||||
|
||||
const renderPaletteButton = (kind: PrimitiveKind) => {
|
||||
|
|
@ -348,7 +438,7 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIndex(null)}
|
||||
onClick={() => setSelectedPath([])}
|
||||
className="mt-1.5 text-violet-700 underline hover:text-violet-900 focus:outline-none focus:ring-2 focus:ring-violet-400 rounded"
|
||||
>
|
||||
Add at top level instead
|
||||
|
|
@ -369,15 +459,14 @@ export function VisualBuilderPane({ descriptor, onChange, validationResult }: Vi
|
|||
) : (
|
||||
<BlockList
|
||||
nodes={descriptor.primitives}
|
||||
selectedIndex={selectedIndex}
|
||||
expandedIndices={expandedIndices}
|
||||
selectedPath={selectedPath}
|
||||
expandedPaths={expandedPaths}
|
||||
basePath={[]}
|
||||
onReorder={handleReorder}
|
||||
onSelect={setSelectedIndex}
|
||||
onSelect={handleSelect}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
onRemove={handleRemove}
|
||||
onParamsChange={handleParamsChange}
|
||||
onNestedReorder={handleNestedReorder}
|
||||
onNestedRemove={handleNestedRemove}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
348
packages/chess/src/util/pending-choices-resume.test.ts
Normal file
348
packages/chess/src/util/pending-choices-resume.test.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
/**
|
||||
* T46 — `submitChoiceAndResume` resume-mechanism tests.
|
||||
*
|
||||
* Validates the locked V1 contract:
|
||||
* 1. Basic resume: the trigger path locates the suspended
|
||||
* request-choice node; resume executes its `params.then`
|
||||
* continuation. The continuation lives INSIDE the suspending
|
||||
* node (T47's contract), not in the surrounding arm's siblings.
|
||||
* 2. Bindings captured at suspension are restored into the resume
|
||||
* context so `params.then` primitives observe the same iteration
|
||||
* scope they would have seen had no suspension occurred.
|
||||
* 3. The player's submitted value is bound under the request-choice
|
||||
* primitive's `bind` name, so subsequent primitives in `then`
|
||||
* can read it via `{ $var: bind }`.
|
||||
* 4. A submit whose `choiceId` doesn't match the top of the stack
|
||||
* throws `runtime.choice-id-mismatch` AND does NOT pop the
|
||||
* frame — out-of-order resolution would violate LIFO and we
|
||||
* need to preserve the frame so the correct id can still
|
||||
* arrive later.
|
||||
* 5. A submit on an empty stack throws `runtime.no-pending-choice`
|
||||
* so the caller's submit-choice handler surfaces the misuse
|
||||
* rather than silently no-oping.
|
||||
*
|
||||
* ## Resume contract = T47's continuation model
|
||||
*
|
||||
* T47 (already landed) puts the continuation in the request-choice
|
||||
* node's own `params.then`, not in the surrounding arm's siblings.
|
||||
* The dispatcher (`runPrimitives` in `triggers.ts`) throws
|
||||
* `SuspendedExecution` from `apply()`, which means siblings AFTER
|
||||
* the request-choice in the same arm are unreachable by
|
||||
* construction. T46 therefore reads `arm[primitiveIndex].params.then`
|
||||
* and re-enters `runPrimitives` against THAT list with the resumed
|
||||
* bindings (captured snapshot + the player's value under
|
||||
* `params.bind`).
|
||||
*
|
||||
* ## Test fixtures
|
||||
*
|
||||
* The resume mechanism needs a real descriptor in
|
||||
* `engine.customModifiers` to walk via `triggerPath`. Tests
|
||||
* register synthetic descriptors with the precise primitive shape
|
||||
* each scenario exercises. We use `seed-attribute` writing to
|
||||
* `HpBonus` on `GAME_ENTITY` as the observable side-effect: a
|
||||
* single `engine.session.get` confirms the post-resume primitive
|
||||
* ran.
|
||||
*
|
||||
* The "request-choice" primitive's `apply()` is not invoked here —
|
||||
* we manually push the `PendingChoice` frame to simulate a prior
|
||||
* suspension. Tests for the suspension path itself live in T47's
|
||||
* own test file; T46's tests are the resume side of the contract.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { GAME_ENTITY, type PendingChoice } from "../schema.js";
|
||||
import {
|
||||
asCustomModifierId,
|
||||
type CustomModifierDescriptor,
|
||||
} from "../modifiers/custom/types.js";
|
||||
import type { EffectPrimitiveNode } from "../modifiers/primitives/types.js";
|
||||
import {
|
||||
pushPendingChoice,
|
||||
submitChoiceAndResume,
|
||||
} from "./pending-choices.js";
|
||||
|
||||
/**
|
||||
* Build a descriptor with the supplied primitive list. All other
|
||||
* fields use plausible defaults — they're irrelevant to the resume
|
||||
* mechanism, which only consults `id` and `primitives`.
|
||||
*/
|
||||
function makeDescriptor(
|
||||
id: string,
|
||||
primitives: readonly EffectPrimitiveNode[],
|
||||
): CustomModifierDescriptor {
|
||||
return {
|
||||
type: "data",
|
||||
id: asCustomModifierId(id),
|
||||
name: id,
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives,
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `PendingChoice` with the supplied resume-relevant fields.
|
||||
* Non-resume fields (kind, prompt, forPlayer) take defaults — the
|
||||
* resume mechanism never inspects them, but the schema requires
|
||||
* presence so we populate plausible values.
|
||||
*/
|
||||
function makeChoice(overrides: {
|
||||
choiceId?: string;
|
||||
descriptorId: string;
|
||||
triggerPath: readonly number[];
|
||||
primitiveIndex: number;
|
||||
bindings?: ReadonlyMap<string, unknown>;
|
||||
}): PendingChoice {
|
||||
return {
|
||||
choiceId: overrides.choiceId ?? "c-1",
|
||||
descriptorId: overrides.descriptorId,
|
||||
triggerPath: overrides.triggerPath,
|
||||
primitiveIndex: overrides.primitiveIndex,
|
||||
bindings: overrides.bindings ?? new Map<string, unknown>(),
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
};
|
||||
}
|
||||
|
||||
describe("submitChoiceAndResume — happy path", () => {
|
||||
it("executes the continuation in the request-choice's `params.then`", () => {
|
||||
// Top-level primitive list: [request-choice]. Its `params.then`
|
||||
// contains the continuation that should run on resume.
|
||||
const desc = makeDescriptor("desc-resume-basic", [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "?",
|
||||
forPlayer: "white",
|
||||
bind: "pickedSquare",
|
||||
// The continuation — runs on resume, writes the sentinel.
|
||||
then: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: { attr: "HpBonus", value: 99 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.customModifiers.register(desc);
|
||||
|
||||
pushPendingChoice(
|
||||
engine,
|
||||
makeChoice({
|
||||
choiceId: "resume-basic",
|
||||
descriptorId: "desc-resume-basic",
|
||||
triggerPath: [], // top-level arm
|
||||
primitiveIndex: 0, // request-choice is at index 0 of that arm
|
||||
}),
|
||||
);
|
||||
|
||||
// Before resume: the continuation has not run.
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBeUndefined();
|
||||
|
||||
submitChoiceAndResume(engine, "resume-basic", 42);
|
||||
|
||||
// After resume: the continuation primitive ran.
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(99);
|
||||
|
||||
// Stack must be empty (frame popped, no leak).
|
||||
expect(
|
||||
engine.session.get(GAME_ENTITY, "PendingChoices"),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitChoiceAndResume — bindings", () => {
|
||||
it("restores bindings captured at suspension into the resumed scope", () => {
|
||||
// The continuation reads a binding via T12's `{$var}` param
|
||||
// resolver. If the resume restored bindings correctly, the
|
||||
// resolved value is the captured one (777) and lands in HpBonus;
|
||||
// if not, the resolver's BindingError surfaces and the test
|
||||
// fails loudly rather than silently storing the unresolved
|
||||
// `{$var:...}` shape.
|
||||
const desc = makeDescriptor("desc-resume-bindings", [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "?",
|
||||
forPlayer: "white",
|
||||
bind: "ignored",
|
||||
then: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
value: { $var: "outerScopeValue" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.customModifiers.register(desc);
|
||||
|
||||
pushPendingChoice(
|
||||
engine,
|
||||
makeChoice({
|
||||
choiceId: "resume-bindings",
|
||||
descriptorId: "desc-resume-bindings",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
bindings: new Map<string, unknown>([["outerScopeValue", 777]]),
|
||||
}),
|
||||
);
|
||||
|
||||
submitChoiceAndResume(engine, "resume-bindings", 0);
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(777);
|
||||
});
|
||||
|
||||
it("binds the submitted value to the request-choice's `bind` name", () => {
|
||||
const desc = makeDescriptor("desc-resume-bind-value", [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "?",
|
||||
forPlayer: "white",
|
||||
bind: "winnerColor",
|
||||
then: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
value: { $var: "winnerColor" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.customModifiers.register(desc);
|
||||
|
||||
pushPendingChoice(
|
||||
engine,
|
||||
makeChoice({
|
||||
choiceId: "resume-bind-value",
|
||||
descriptorId: "desc-resume-bind-value",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
// Player submits 123 — should land in HpBonus via the bind name.
|
||||
submitChoiceAndResume(engine, "resume-bind-value", 123);
|
||||
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(123);
|
||||
});
|
||||
|
||||
it("submitted value SHADOWS a same-named binding captured at suspension", () => {
|
||||
// Edge case worth pinning: if the captured bindings already
|
||||
// contain a key matching the request-choice's `bind`, the
|
||||
// submitted value wins. (Otherwise a player's choice could be
|
||||
// silently overridden by a stale outer scope name collision.)
|
||||
const desc = makeDescriptor("desc-resume-shadow", [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "?",
|
||||
forPlayer: "white",
|
||||
bind: "score",
|
||||
then: [
|
||||
{
|
||||
kind: "seed-attribute",
|
||||
params: {
|
||||
attr: "HpBonus",
|
||||
value: { $var: "score" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.customModifiers.register(desc);
|
||||
|
||||
pushPendingChoice(
|
||||
engine,
|
||||
makeChoice({
|
||||
choiceId: "resume-shadow",
|
||||
descriptorId: "desc-resume-shadow",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
bindings: new Map<string, unknown>([["score", 1]]),
|
||||
}),
|
||||
);
|
||||
|
||||
submitChoiceAndResume(engine, "resume-shadow", 999);
|
||||
|
||||
// The submitted 999 wins over the stale 1.
|
||||
expect(engine.session.get(GAME_ENTITY, "HpBonus")).toBe(999);
|
||||
});
|
||||
});
|
||||
|
||||
describe("submitChoiceAndResume — error handling", () => {
|
||||
it("throws runtime.choice-id-mismatch when the choiceId differs from the top of stack", () => {
|
||||
const desc = makeDescriptor("desc-mismatch", [
|
||||
{
|
||||
kind: "request-choice",
|
||||
params: {
|
||||
kind: "square",
|
||||
prompt: "?",
|
||||
forPlayer: "white",
|
||||
bind: "x",
|
||||
then: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const engine = new ChessEngine();
|
||||
engine.customModifiers.register(desc);
|
||||
|
||||
pushPendingChoice(
|
||||
engine,
|
||||
makeChoice({
|
||||
choiceId: "the-real-id",
|
||||
descriptorId: "desc-mismatch",
|
||||
triggerPath: [],
|
||||
primitiveIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(() =>
|
||||
submitChoiceAndResume(engine, "wrong-id", 0),
|
||||
).toThrow(/runtime\.choice-id-mismatch/);
|
||||
|
||||
// CRITICAL: the frame must NOT have been popped. A mismatched
|
||||
// submit is recoverable — the correct choiceId can still
|
||||
// arrive and resolve the frame; popping on mismatch would leak
|
||||
// the suspended state.
|
||||
const remaining = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0]?.choiceId).toBe("the-real-id");
|
||||
});
|
||||
|
||||
it("throws runtime.no-pending-choice when the stack is empty", () => {
|
||||
const engine = new ChessEngine();
|
||||
expect(() =>
|
||||
submitChoiceAndResume(engine, "any-id", 0),
|
||||
).toThrow(/runtime\.no-pending-choice/);
|
||||
});
|
||||
});
|
||||
232
packages/chess/src/util/pending-choices.test.ts
Normal file
232
packages/chess/src/util/pending-choices.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* T45 — pending-choices stack helper tests.
|
||||
*
|
||||
* Verifies the locked V1 contract:
|
||||
* 1. `pushPendingChoice` appends to the top of the stack (LIFO
|
||||
* ordering establishes via two consecutive pushes).
|
||||
* 2. `popPendingChoice` returns the most-recently-pushed frame
|
||||
* (LIFO removal).
|
||||
* 3. `peekPendingChoice` returns the top frame WITHOUT removing
|
||||
* it — repeat reads observe the same value.
|
||||
* 4. Empty-stack reads (`pop`/`peek`) return `undefined` rather
|
||||
* than throwing — callers don't need a length-guard.
|
||||
* 5. Pushing past the cap (depth 8) throws a runtime error tagged
|
||||
* `runtime.choice-depth-exceeded`. The 8th push succeeds; the
|
||||
* 9th fails. Failure leaves the stack untouched.
|
||||
* 6. `serializePendingChoice` / `deserializePendingChoice` round-
|
||||
* trip through `JSON.stringify` losslessly. Critically, the
|
||||
* `bindings` Map survives the trip — naive `JSON.stringify` on
|
||||
* a Map silently drops every entry, so the explicit transform
|
||||
* is the whole point of this test.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ChessEngine } from "../engine.js";
|
||||
import { GAME_ENTITY, type PendingChoice } from "../schema.js";
|
||||
import {
|
||||
MAX_CHOICE_DEPTH,
|
||||
deserializePendingChoice,
|
||||
peekPendingChoice,
|
||||
popPendingChoice,
|
||||
pushPendingChoice,
|
||||
serializePendingChoice,
|
||||
} from "./pending-choices.js";
|
||||
|
||||
/**
|
||||
* Make a `PendingChoice` with optional overrides. All fields default
|
||||
* to plausible-but-distinct values so equality comparisons across
|
||||
* frames don't accidentally coincide.
|
||||
*/
|
||||
function makeChoice(overrides: Partial<PendingChoice> = {}): PendingChoice {
|
||||
return {
|
||||
choiceId: "choice-1",
|
||||
descriptorId: "descriptor-A",
|
||||
triggerPath: [0, 1, 2],
|
||||
primitiveIndex: 4,
|
||||
bindings: new Map<string, unknown>([
|
||||
["chooser", 7],
|
||||
["pickedSquare", 28],
|
||||
]),
|
||||
kind: "square",
|
||||
prompt: "Pick a square",
|
||||
forPlayer: "white",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pushPendingChoice", () => {
|
||||
it("appends to the top of the stack (LIFO order)", () => {
|
||||
const engine = new ChessEngine();
|
||||
const first = makeChoice({ choiceId: "first" });
|
||||
const second = makeChoice({ choiceId: "second" });
|
||||
|
||||
pushPendingChoice(engine, first);
|
||||
pushPendingChoice(engine, second);
|
||||
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(stack.length).toBe(2);
|
||||
// Bottom of stack = first push; top = second push.
|
||||
expect(stack[0]?.choiceId).toBe("first");
|
||||
expect(stack[1]?.choiceId).toBe("second");
|
||||
});
|
||||
|
||||
it("throws runtime.choice-depth-exceeded on the 9th push and leaves the stack at 8", () => {
|
||||
const engine = new ChessEngine();
|
||||
for (let i = 0; i < MAX_CHOICE_DEPTH; i++) {
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: `c${i}` }));
|
||||
}
|
||||
// The 9th push must throw with the exact error code.
|
||||
expect(() =>
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "overflow" })),
|
||||
).toThrow(/runtime\.choice-depth-exceeded/);
|
||||
|
||||
// Stack must be unchanged after the failed push.
|
||||
const stack = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(stack.length).toBe(MAX_CHOICE_DEPTH);
|
||||
expect(stack[stack.length - 1]?.choiceId).toBe(`c${MAX_CHOICE_DEPTH - 1}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("popPendingChoice", () => {
|
||||
it("returns the most-recently-pushed frame (LIFO)", () => {
|
||||
const engine = new ChessEngine();
|
||||
const first = makeChoice({ choiceId: "first" });
|
||||
const second = makeChoice({ choiceId: "second" });
|
||||
pushPendingChoice(engine, first);
|
||||
pushPendingChoice(engine, second);
|
||||
|
||||
const popped = popPendingChoice(engine);
|
||||
expect(popped?.choiceId).toBe("second");
|
||||
|
||||
// After one pop, only `first` remains.
|
||||
const remaining = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(remaining.length).toBe(1);
|
||||
expect(remaining[0]?.choiceId).toBe("first");
|
||||
});
|
||||
|
||||
it("returns undefined when the stack is empty (no throw)", () => {
|
||||
const engine = new ChessEngine();
|
||||
expect(popPendingChoice(engine)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("peekPendingChoice", () => {
|
||||
it("returns the top frame without modifying the stack", () => {
|
||||
const engine = new ChessEngine();
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "a" }));
|
||||
pushPendingChoice(engine, makeChoice({ choiceId: "b" }));
|
||||
|
||||
const before = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
|
||||
const peeked1 = peekPendingChoice(engine);
|
||||
const peeked2 = peekPendingChoice(engine);
|
||||
|
||||
// Repeat reads return the same top frame.
|
||||
expect(peeked1?.choiceId).toBe("b");
|
||||
expect(peeked2?.choiceId).toBe("b");
|
||||
|
||||
// Stack is unchanged after the peeks.
|
||||
const after = engine.session.get(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
) as readonly PendingChoice[];
|
||||
expect(after.length).toBe(before.length);
|
||||
expect(after[after.length - 1]?.choiceId).toBe("b");
|
||||
});
|
||||
|
||||
it("returns undefined when the stack is empty (no throw)", () => {
|
||||
const engine = new ChessEngine();
|
||||
expect(peekPendingChoice(engine)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("serialize/deserialize PendingChoice", () => {
|
||||
it("round-trips through JSON.stringify with the bindings Map intact", () => {
|
||||
const original: PendingChoice = makeChoice({
|
||||
choiceId: "rt",
|
||||
descriptorId: "D-7",
|
||||
triggerPath: [3, 1, 4, 1, 5],
|
||||
primitiveIndex: 9,
|
||||
bindings: new Map<string, unknown>([
|
||||
["winnerColor", "white"],
|
||||
["pickedPieceId", 42],
|
||||
["coinFlipResult", true],
|
||||
["nestedObj", { a: 1, b: [2, 3] }],
|
||||
]),
|
||||
kind: "piece",
|
||||
prompt: "Pick a piece to promote",
|
||||
forPlayer: "black",
|
||||
timeout: 30_000,
|
||||
expiresAtTimestamp: 1_700_000_000_000,
|
||||
});
|
||||
|
||||
// Sanity: a naive JSON.stringify on the in-memory shape silently
|
||||
// loses the bindings Map. This is the bug the helpers exist to
|
||||
// prevent — assert it explicitly so a future Map-aware
|
||||
// JSON.stringify polyfill doesn't make this test trivially pass.
|
||||
const naive = JSON.parse(JSON.stringify(original)) as {
|
||||
bindings: Record<string, unknown>;
|
||||
};
|
||||
expect(naive.bindings).toEqual({});
|
||||
|
||||
// Real path: serialize → JSON.stringify → JSON.parse → deserialize.
|
||||
const serialized = serializePendingChoice(original);
|
||||
const wire = JSON.stringify(serialized);
|
||||
const parsed = JSON.parse(wire) as ReturnType<
|
||||
typeof serializePendingChoice
|
||||
>;
|
||||
const restored = deserializePendingChoice(parsed);
|
||||
|
||||
// Every scalar / array field survives byte-identical.
|
||||
expect(restored.choiceId).toBe(original.choiceId);
|
||||
expect(restored.descriptorId).toBe(original.descriptorId);
|
||||
expect(restored.triggerPath).toEqual(original.triggerPath);
|
||||
expect(restored.primitiveIndex).toBe(original.primitiveIndex);
|
||||
expect(restored.kind).toBe(original.kind);
|
||||
expect(restored.prompt).toBe(original.prompt);
|
||||
expect(restored.forPlayer).toBe(original.forPlayer);
|
||||
expect(restored.timeout).toBe(original.timeout);
|
||||
expect(restored.expiresAtTimestamp).toBe(original.expiresAtTimestamp);
|
||||
|
||||
// Bindings: Map shape preserved AND every entry survives
|
||||
// (including the nested-object entry which `Map.toJSON` would
|
||||
// drop without the explicit transform).
|
||||
expect(restored.bindings).toBeInstanceOf(Map);
|
||||
expect(restored.bindings.size).toBe(original.bindings.size);
|
||||
expect(restored.bindings.get("winnerColor")).toBe("white");
|
||||
expect(restored.bindings.get("pickedPieceId")).toBe(42);
|
||||
expect(restored.bindings.get("coinFlipResult")).toBe(true);
|
||||
expect(restored.bindings.get("nestedObj")).toEqual({ a: 1, b: [2, 3] });
|
||||
|
||||
// Insertion order is preserved (matters for resume context
|
||||
// determinism — the param walker iterates bindings in order).
|
||||
expect(Array.from(restored.bindings.keys())).toEqual(
|
||||
Array.from(original.bindings.keys()),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits optional fields from the serialized shape when absent", () => {
|
||||
const choice = makeChoice();
|
||||
expect(choice.timeout).toBeUndefined();
|
||||
expect(choice.expiresAtTimestamp).toBeUndefined();
|
||||
|
||||
const serialized = serializePendingChoice(choice);
|
||||
expect("timeout" in serialized).toBe(false);
|
||||
expect("expiresAtTimestamp" in serialized).toBe(false);
|
||||
|
||||
const restored = deserializePendingChoice(serialized);
|
||||
expect(restored.timeout).toBeUndefined();
|
||||
expect(restored.expiresAtTimestamp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
483
packages/chess/src/util/pending-choices.ts
Normal file
483
packages/chess/src/util/pending-choices.ts
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
/**
|
||||
* T45 — LIFO stack helpers for suspended request-choice frames.
|
||||
*
|
||||
* The `PendingChoices` attr on `GAME_ENTITY` (see schema.ts) holds a
|
||||
* stack of {@link PendingChoice} frames. Each frame represents a
|
||||
* `request-choice` primitive (T47) that suspended trigger execution
|
||||
* pending a player decision. Resolution is **strict LIFO**: the
|
||||
* innermost (most-recently-pushed) frame is the one the player
|
||||
* answers next, even if multiple choices are nested. This matches
|
||||
* the natural cascade order — an outer arm fires inner arms which
|
||||
* may themselves request further choices.
|
||||
*
|
||||
* ## Why a stack, not a queue
|
||||
*
|
||||
* Trigger arms run synchronously in cascade order (`decisions.md`
|
||||
* "Trigger Reentrance — Deferred Queue"). When an inner arm
|
||||
* suspends, control unwinds back to the dispatcher, but the OUTER
|
||||
* arm's continuation must not run until the inner choice resolves —
|
||||
* otherwise the outer arm sees stale state. LIFO is the only
|
||||
* ordering that preserves the call-graph semantics. Queue ordering
|
||||
* (FIFO) would resume the outer arm first, breaking nesting.
|
||||
*
|
||||
* ## Depth cap = 8
|
||||
*
|
||||
* `MAX_CHOICE_DEPTH = 8` mirrors `RUNTIME_DEPTH_HARD_CAP` (plan
|
||||
* T15). Eight levels of nested player choices in a single trigger
|
||||
* cascade is already pathological — the cap exists to short-circuit
|
||||
* runaway descriptor loops where each choice's resolution fires
|
||||
* another trigger that requests yet another choice. Overflow throws
|
||||
* a runtime error tagged `runtime.choice-depth-exceeded` so it
|
||||
* surfaces in the WS error-broadcast pipeline (T43) the same way
|
||||
* cascade-depth overflow does.
|
||||
*
|
||||
* The cap is enforced at PUSH time, not at validator time, because
|
||||
* the depth is a function of runtime state (which descriptors are
|
||||
* active, which moves have been played) — the validator cannot know
|
||||
* whether a particular trigger arm will recurse 0, 1, or 8 levels
|
||||
* deep without simulating execution.
|
||||
*
|
||||
* ## Immutability discipline
|
||||
*
|
||||
* Every helper that *modifies* the stack creates a new array via
|
||||
* spread / `slice`. The plan's must-not-do list bans in-place
|
||||
* mutation: the stack is stored as `readonly PendingChoice[]` in
|
||||
* `ChessAttrMap`, and consumers (the WS broadcaster, the
|
||||
* `submit-choice` handler) may hold references to the stack
|
||||
* snapshot from a prior moment. Mutating in place would silently
|
||||
* corrupt their view; replacing the whole array with a new
|
||||
* reference makes the change explicit at the session-fact level.
|
||||
*
|
||||
* ## Serialization
|
||||
*
|
||||
* `PendingChoice.bindings` is a `ReadonlyMap<string, unknown>` — by
|
||||
* design (T0 lock) — but `JSON.stringify` does not handle Maps:
|
||||
* `JSON.stringify(new Map([["a", 1]]))` yields `"{}"` and silently
|
||||
* drops every entry. The save/load pipeline therefore has to
|
||||
* convert each `PendingChoice` through {@link serializePendingChoice}
|
||||
* / {@link deserializePendingChoice} at the boundary. The on-the-
|
||||
* wire shape ({@link SerializedPendingChoice}) replaces the Map
|
||||
* with `ReadonlyArray<[string, unknown]>`, which is the canonical
|
||||
* `Map.entries()` representation and round-trips cleanly through
|
||||
* JSON.
|
||||
*
|
||||
* The in-memory shape stays a Map everywhere else because callers
|
||||
* (the param-walker, the resume mechanism) want O(1) keyed lookup,
|
||||
* not array scanning.
|
||||
*/
|
||||
import { GAME_ENTITY, type PendingChoice } from "../schema.js";
|
||||
import type { ChessEngine } from "../engine.js";
|
||||
import { runPrimitives } from "../modifiers/triggers.js";
|
||||
import type {
|
||||
EffectPrimitiveNode,
|
||||
} from "../modifiers/primitives/types.js";
|
||||
import type { BindingValue } from "../modifiers/primitives/context.js";
|
||||
import { PRIMITIVE_REGISTRY } from "../modifiers/primitives/registry.js";
|
||||
|
||||
/**
|
||||
* Hard cap on simultaneous suspended choice frames. Locked at T0:
|
||||
* `decisions.md` "Player Choice — Suspended Execution" — "Maximum
|
||||
* stack depth = 8". Aligns with `RUNTIME_DEPTH_HARD_CAP` from the
|
||||
* cascade-depth limit; the two systems are orthogonal but share the
|
||||
* same numeric ceiling because both protect against descriptor
|
||||
* recursion bombs. Treat any change to this number as a
|
||||
* plan-amending event.
|
||||
*/
|
||||
export const MAX_CHOICE_DEPTH = 8;
|
||||
|
||||
/**
|
||||
* Push a new {@link PendingChoice} frame onto the stack.
|
||||
*
|
||||
* Throws `runtime.choice-depth-exceeded` if the resulting stack
|
||||
* would exceed {@link MAX_CHOICE_DEPTH} — the existing stack is
|
||||
* left untouched so callers don't need to roll back partial state.
|
||||
*
|
||||
* Always replaces the stack reference (never mutates the prior
|
||||
* array) so consumers holding a snapshot from a previous moment
|
||||
* keep observing the old shape.
|
||||
*/
|
||||
export function pushPendingChoice(
|
||||
engine: ChessEngine,
|
||||
choice: PendingChoice,
|
||||
): void {
|
||||
const stack =
|
||||
(engine.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly PendingChoice[]
|
||||
| undefined) ?? [];
|
||||
if (stack.length >= MAX_CHOICE_DEPTH) {
|
||||
throw new Error(
|
||||
`runtime.choice-depth-exceeded: stack depth ${stack.length} >= ${MAX_CHOICE_DEPTH}`,
|
||||
);
|
||||
}
|
||||
engine.session.insert(GAME_ENTITY, "PendingChoices", [...stack, choice]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop and return the top (innermost / most-recently-pushed) frame.
|
||||
* Returns `undefined` if the stack is empty so callers don't need
|
||||
* to peek-then-pop.
|
||||
*
|
||||
* Replaces the stack reference with a new array (sliced excluding
|
||||
* the top element) for the same immutability reason
|
||||
* `pushPendingChoice` rebuilds on push.
|
||||
*/
|
||||
export function popPendingChoice(
|
||||
engine: ChessEngine,
|
||||
): PendingChoice | undefined {
|
||||
const stack =
|
||||
(engine.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly PendingChoice[]
|
||||
| undefined) ?? [];
|
||||
if (stack.length === 0) return undefined;
|
||||
const top = stack[stack.length - 1];
|
||||
engine.session.insert(
|
||||
GAME_ENTITY,
|
||||
"PendingChoices",
|
||||
stack.slice(0, -1),
|
||||
);
|
||||
return top;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the top frame without mutating the stack. Returns
|
||||
* `undefined` if the stack is empty. Used by the WS broadcaster
|
||||
* (T44) to render the prompt currently awaiting a response.
|
||||
*/
|
||||
export function peekPendingChoice(
|
||||
engine: ChessEngine,
|
||||
): PendingChoice | undefined {
|
||||
const stack =
|
||||
(engine.session.get(GAME_ENTITY, "PendingChoices") as
|
||||
| readonly PendingChoice[]
|
||||
| undefined) ?? [];
|
||||
return stack[stack.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* On-disk / on-the-wire shape of a {@link PendingChoice}. Identical
|
||||
* to the in-memory type EXCEPT `bindings` is encoded as
|
||||
* `ReadonlyArray<[string, unknown]>` (the `Map.entries()` shape)
|
||||
* instead of a `ReadonlyMap`. This is the only field that requires
|
||||
* a transform; every other field is already a JSON-native type.
|
||||
*/
|
||||
export interface SerializedPendingChoice {
|
||||
readonly choiceId: string;
|
||||
readonly descriptorId: string;
|
||||
readonly triggerPath: readonly number[];
|
||||
readonly primitiveIndex: number;
|
||||
readonly bindings: ReadonlyArray<readonly [string, unknown]>;
|
||||
readonly kind: "rps" | "piece" | "square" | "column" | "row";
|
||||
readonly prompt: string;
|
||||
readonly forPlayer: "white" | "black" | "both";
|
||||
readonly timeout?: number;
|
||||
readonly expiresAtTimestamp?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an in-memory {@link PendingChoice} into its JSON-safe
|
||||
* counterpart. Call this at the save/serialize boundary —
|
||||
* everywhere else the in-memory Map shape is preferred for O(1)
|
||||
* keyed reads.
|
||||
*
|
||||
* Insertion order of the bindings Map is preserved by
|
||||
* `Array.from(map.entries())` per the ES spec (Maps iterate in
|
||||
* insertion order). This matters because the resume mechanism
|
||||
* (T46) re-populates a fresh context from the deserialized array
|
||||
* and must observe the same key order the original primitive saw.
|
||||
*/
|
||||
export function serializePendingChoice(
|
||||
choice: PendingChoice,
|
||||
): SerializedPendingChoice {
|
||||
return {
|
||||
choiceId: choice.choiceId,
|
||||
descriptorId: choice.descriptorId,
|
||||
triggerPath: choice.triggerPath,
|
||||
primitiveIndex: choice.primitiveIndex,
|
||||
bindings: Array.from(choice.bindings.entries()),
|
||||
kind: choice.kind,
|
||||
prompt: choice.prompt,
|
||||
forPlayer: choice.forPlayer,
|
||||
...(choice.timeout !== undefined ? { timeout: choice.timeout } : {}),
|
||||
...(choice.expiresAtTimestamp !== undefined
|
||||
? { expiresAtTimestamp: choice.expiresAtTimestamp }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of {@link serializePendingChoice}: rehydrates the
|
||||
* `bindings` field back into a `Map` so downstream consumers see
|
||||
* the same shape they would have seen pre-serialization.
|
||||
*
|
||||
* The Map is constructed from a fresh array copy of the entries so
|
||||
* mutating the deserialized choice's bindings (forbidden by the
|
||||
* `ReadonlyMap` typing, but defended at the runtime boundary)
|
||||
* cannot bleed back into the serialized snapshot.
|
||||
*/
|
||||
export function deserializePendingChoice(
|
||||
serialized: SerializedPendingChoice,
|
||||
): PendingChoice {
|
||||
return {
|
||||
choiceId: serialized.choiceId,
|
||||
descriptorId: serialized.descriptorId,
|
||||
triggerPath: serialized.triggerPath,
|
||||
primitiveIndex: serialized.primitiveIndex,
|
||||
bindings: new Map(serialized.bindings),
|
||||
kind: serialized.kind,
|
||||
prompt: serialized.prompt,
|
||||
forPlayer: serialized.forPlayer,
|
||||
...(serialized.timeout !== undefined
|
||||
? { timeout: serialized.timeout }
|
||||
: {}),
|
||||
...(serialized.expiresAtTimestamp !== undefined
|
||||
? { expiresAtTimestamp: serialized.expiresAtTimestamp }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a descriptor's primitive tree along `triggerPath` and return
|
||||
* the surrounding primitive ARRAY (the "arm") at the destination,
|
||||
* along with the suspended primitive node itself (so callers can
|
||||
* read its `bind` param). Returns `undefined` if the path is invalid
|
||||
* (out-of-bounds index, intermediate node has no children).
|
||||
*
|
||||
* Path semantics (locked by T0 / T46 plan):
|
||||
* - Empty path `[]` → the arm is `descriptor.primitives` itself.
|
||||
* - Non-empty path `[i0, i1, …, iN]` → walk into
|
||||
* `descriptor.primitives[i0]`, descend via its
|
||||
* `childPrimitives()` to get an inner array, index `[i1]` into
|
||||
* THAT array, descend again, … the FINAL index `iN` selects the
|
||||
* node whose `childPrimitives()` IS the resume arm.
|
||||
*
|
||||
* Concretely, every step EXCEPT the last walks "node → its child
|
||||
* array → next child node". The final step walks "node → its child
|
||||
* array (= the arm)". This matches the documented example
|
||||
* "[0, 2] means: descriptor.primitives[0].params.primitives[2]" —
|
||||
* `descriptor.primitives[0]` is the on-turn-start (or random-pick,
|
||||
* conditional, etc.) wrapper; its child array contains the
|
||||
* request-choice at index 2; the request-choice's siblings are the
|
||||
* arm we resume.
|
||||
*
|
||||
* Returning the surrounding ARRAY (not just the node) is what the
|
||||
* resume mechanism needs: `runPrimitives` expects a flat list of
|
||||
* sibling primitives to execute, and the suspended request-choice
|
||||
* lives at `arm[primitiveIndex]` with the to-resume tail at
|
||||
* `arm.slice(primitiveIndex + 1)`.
|
||||
*/
|
||||
function walkTriggerPath(
|
||||
topPrimitives: readonly EffectPrimitiveNode[],
|
||||
triggerPath: readonly number[],
|
||||
): readonly EffectPrimitiveNode[] | undefined {
|
||||
// Empty path = the request-choice was at the top level of the
|
||||
// descriptor's `primitives` array. The arm is `topPrimitives` itself.
|
||||
if (triggerPath.length === 0) return topPrimitives;
|
||||
|
||||
let arm: readonly EffectPrimitiveNode[] = topPrimitives;
|
||||
for (let depth = 0; depth < triggerPath.length; depth++) {
|
||||
const idx = triggerPath[depth]!;
|
||||
if (idx < 0 || idx >= arm.length) return undefined;
|
||||
const node = arm[idx]!;
|
||||
const primitive = PRIMITIVE_REGISTRY.get(node.kind);
|
||||
if (primitive === undefined || primitive.childPrimitives === undefined) {
|
||||
// Intermediate node has no nested children — path can't continue.
|
||||
return undefined;
|
||||
}
|
||||
let children: readonly EffectPrimitiveNode[];
|
||||
try {
|
||||
children = primitive.childPrimitives(node.params);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
arm = children;
|
||||
}
|
||||
return arm;
|
||||
}
|
||||
|
||||
/**
|
||||
* T46 — pop the top {@link PendingChoice} frame, restore its
|
||||
* captured bindings, bind the player's submitted value to the
|
||||
* request-choice's declared `bind` name, and resume
|
||||
* {@link runPrimitives} on the suspended continuation.
|
||||
*
|
||||
* ## Continuation source = `params.then`, NOT sibling slice
|
||||
*
|
||||
* T47's `request-choice` primitive (see
|
||||
* `modifiers/primitives/request-choice.ts`) stores the
|
||||
* "what runs after the player picks" list under its OWN
|
||||
* `params.then` — not in the surrounding arm's siblings. The plan
|
||||
* doc T46 sketches an `arm.slice(primitiveIndex + 1)` model, but
|
||||
* T47 (already landed) intentionally moved the continuation INTO
|
||||
* the request-choice node so:
|
||||
* - the continuation is statically discoverable via
|
||||
* `childPrimitives(params)` for tree walkers (validator T34's
|
||||
* binding-scope walker, manifest cleanup);
|
||||
* - sibling primitives AFTER the request-choice in its arm are
|
||||
* unreachable by construction (the `apply()` throws
|
||||
* `SuspendedExecution`, the dispatcher never iterates past
|
||||
* the throw), so they couldn't be a continuation even if we
|
||||
* wanted them to be;
|
||||
* - nesting works the natural way — `then` is just another
|
||||
* `EffectPrimitiveNode[]`, so a request-choice's continuation
|
||||
* can itself contain another request-choice (LIFO stack
|
||||
* handles the recursion).
|
||||
*
|
||||
* Resume therefore: walk to the arm via `triggerPath`, locate the
|
||||
* request-choice at `arm[primitiveIndex]`, read its `params.then`,
|
||||
* and feed THAT array (not `arm.slice(...)`) to `runPrimitives`.
|
||||
*
|
||||
* ## Locking notes
|
||||
*
|
||||
* `PendingChoice` is the locked T0 shape — we DELIBERATELY do NOT
|
||||
* extend it (e.g. with the resume `pieceId` or the request-choice's
|
||||
* `bind` name) because the contract is fixed. Both pieces of info
|
||||
* are recoverable from the descriptor at resume time:
|
||||
*
|
||||
* - `pieceId`: the resume target is `GAME_ENTITY`. request-choice
|
||||
* is a game-level event ("pick a piece / square / column");
|
||||
* inner primitives that need a piece target reach for it via
|
||||
* bindings (the choice value itself, plus any prior
|
||||
* `for-each-*` scope) rather than implicit `ctx.pieceId`. If a
|
||||
* future descriptor genuinely needs the original suspending
|
||||
* piece, it must capture that piece's id into bindings BEFORE
|
||||
* the request-choice fires (`for-each-piece` does this
|
||||
* naturally).
|
||||
*
|
||||
* - `bind` name: the suspended primitive node lives at
|
||||
* `arm[primitiveIndex]` and is the request-choice itself. T47's
|
||||
* contract guarantees the node's `params.bind` is the binding
|
||||
* name for the player's value. We read it back here. When the
|
||||
* suspended node is missing a `bind` (validator should have
|
||||
* caught it; defensive case), the resume runs WITHOUT binding
|
||||
* the value — matching how a no-bind variant would behave.
|
||||
*
|
||||
* ## Descriptor lookup
|
||||
*
|
||||
* Descriptors are stored on the per-engine
|
||||
* {@link ChessEngine#customModifiers} registry (see ADR-4). We
|
||||
* resolve `descriptorId → descriptor` via that registry. If the
|
||||
* descriptor is gone (rare: profile reset between push and submit),
|
||||
* the resume throws `runtime.descriptor-not-found` so the calling
|
||||
* server / submit-choice handler can surface a diagnosable error
|
||||
* to the player rather than silently dropping the resume. The pop
|
||||
* has ALREADY happened at this point — the alternative (resume-
|
||||
* before-pop or pop-only-on-success) would either loop forever on
|
||||
* a missing descriptor or leak a dead frame; popping unconditionally
|
||||
* is the cleanest failure mode.
|
||||
*
|
||||
* ## Cascade depth
|
||||
*
|
||||
* Resume re-enters `runPrimitives` at `cascadeDepth = 0`. The
|
||||
* suspending arm's original cascadeDepth is NOT preserved on
|
||||
* `PendingChoice` (locked schema). This is acceptable for V1: the
|
||||
* cascade-depth cap (T15, hard cap = 8) protects against runaway
|
||||
* trigger chains within a single arm, and a request-choice is by
|
||||
* design a HARD BOUNDARY between arms (the player's network
|
||||
* round-trip splits "pre-choice" from "post-choice"). Restarting
|
||||
* the cascade counter from 0 on resume mirrors how a player-
|
||||
* initiated `apply-modifier` action enters at depth 0.
|
||||
*
|
||||
* ## Strict LIFO + choiceId match
|
||||
*
|
||||
* The choiceId on the submit MUST match the top of the stack.
|
||||
* Mismatches throw `runtime.choice-id-mismatch` rather than
|
||||
* searching the stack — out-of-order resolution would violate the
|
||||
* LIFO call-graph semantics documented at the top of this file.
|
||||
* Mismatches do NOT pop the frame: the correct choiceId can still
|
||||
* arrive later. Empty-stack submits throw
|
||||
* `runtime.no-pending-choice`.
|
||||
*/
|
||||
export function submitChoiceAndResume(
|
||||
engine: ChessEngine,
|
||||
choiceId: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
const top = peekPendingChoice(engine);
|
||||
if (top === undefined) {
|
||||
throw new Error("runtime.no-pending-choice");
|
||||
}
|
||||
if (top.choiceId !== choiceId) {
|
||||
// Do NOT pop on mismatch — the correct choiceId may still
|
||||
// arrive. Popping here would lose the suspended frame.
|
||||
throw new Error(
|
||||
`runtime.choice-id-mismatch: expected ${top.choiceId}, got ${choiceId}`,
|
||||
);
|
||||
}
|
||||
// Pop FIRST — re-running the resume on a still-pushed frame would
|
||||
// loop indefinitely (a continuation that itself fires another
|
||||
// request-choice would push a NEW frame; if this frame were still
|
||||
// on the stack the LIFO ordering would be wrong). The pop is
|
||||
// unconditional from here on; see header doc.
|
||||
popPendingChoice(engine);
|
||||
|
||||
const descriptor = engine.customModifiers.get(top.descriptorId);
|
||||
if (descriptor === undefined) {
|
||||
throw new Error(
|
||||
`runtime.descriptor-not-found: ${top.descriptorId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const arm = walkTriggerPath(descriptor.primitives, top.triggerPath);
|
||||
if (arm === undefined) {
|
||||
throw new Error(
|
||||
`runtime.trigger-path-invalid: ${top.triggerPath.join(",")}`,
|
||||
);
|
||||
}
|
||||
if (top.primitiveIndex < 0 || top.primitiveIndex >= arm.length) {
|
||||
throw new Error(
|
||||
`runtime.trigger-path-invalid: primitiveIndex ${top.primitiveIndex} out of range for arm length ${arm.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const suspendedNode = arm[top.primitiveIndex]!;
|
||||
// T47's request-choice schema stores the bind name under
|
||||
// `params.bind` and the continuation under `params.then`.
|
||||
// Defensively read via a structural check — the unknown-typed
|
||||
// params shape mirrors the rest of the primitive tree's `unknown`
|
||||
// discipline.
|
||||
const params = suspendedNode.params;
|
||||
let bindName: string | undefined;
|
||||
let continuation: readonly EffectPrimitiveNode[] = [];
|
||||
if (params !== null && typeof params === "object") {
|
||||
if (
|
||||
"bind" in params &&
|
||||
typeof (params as { bind: unknown }).bind === "string"
|
||||
) {
|
||||
bindName = (params as { bind: string }).bind;
|
||||
}
|
||||
if (
|
||||
"then" in params &&
|
||||
Array.isArray((params as { then: unknown }).then)
|
||||
) {
|
||||
continuation = (params as { then: readonly EffectPrimitiveNode[] }).then;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore captured bindings into a FRESH Map so the resume's
|
||||
// `runPrimitives` cannot mutate the snapshot stored on the
|
||||
// (already-popped, but possibly still referenced by callers)
|
||||
// PendingChoice. The submitted value SHADOWS any same-named
|
||||
// entry in the captured scope — a stale outer binding must not
|
||||
// override the player's deliberate pick.
|
||||
const restored = new Map<string, BindingValue>(
|
||||
top.bindings as ReadonlyMap<string, BindingValue>,
|
||||
);
|
||||
if (bindName !== undefined) {
|
||||
restored.set(bindName, value as BindingValue);
|
||||
}
|
||||
|
||||
// Resume on GAME_ENTITY (the canonical game-level entity for
|
||||
// request-choice cascades). Empty `continuation` is fine —
|
||||
// `runPrimitives` no-ops on an empty node list, which is the
|
||||
// natural behaviour when the request-choice declared no `then`.
|
||||
runPrimitives(
|
||||
engine,
|
||||
GAME_ENTITY,
|
||||
continuation,
|
||||
/* depth */ 0,
|
||||
/* event */ undefined,
|
||||
restored,
|
||||
/* cascadeDepth */ 0,
|
||||
/* suppressTriggers */ false,
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue