fix(chess/ui): narrate.ts \u2014 V3 resolver shapes + 9 missing narrators
Fix two bugs in the Custom Modifier Editor's live preview pane that
surfaced when loading thressgame-100 epic recipes:
BUG 1 \u2014 'unknown primitive: <kind>'
The narrator dispatcher (KIND_NARRATORS) was missing entries for the 9
new primitives shipped across the thressgame-100 epic. Loading any recipe
using them showed 'unknown primitive: add-resource' (etc.) in the preview.
Added 9 narrators (alphabetically inserted into KIND_NARRATORS):
- on-attr-expire (W2) \u2014 'When the <attr> attr on <target> expires: <children>'
- decrement-attr-each-turn (W2) \u2014 'each turn-end, subtract 1 from <attr> on <target>'
- set-board-topology (W4) \u2014 'set board topology to <value>'
- link-pieces (W4) \u2014 'link the pieces at <a> and <b>'
- unlink-pieces (W4) \u2014 'unlink the pieces at <a> and <b>'
- on-piece-pair-link-broken (W4) \u2014 'When a linked partner is destroyed: <children>'
- add-resource (W5) \u2014 'give <player> +<amount> score' (or 'subtract from')
- spend-resource (W5) \u2014 'If <player> has <amount> score, spend it, then: <then>; otherwise: <else>'
- on-resource-changed (W5) \u2014 'When <player>'s score crosses <threshold> <direction>: <children>'
BUG 2 \u2014 '[object Object]' for V3 resolver shapes
fmtPieceTarget (line 125) and inline square handling fell through to
String(t) when the field carried a V3 resolver shape ({$var}, {ctx-self-id},
{ctx-self-marker-id}, {ctx-attr}, {ctx-build}, {add/sub/mul/mod}).
User-reported case: tpl-treasure-chest's destroy-marker arm rendered as
'destroy [object Object]'.
Added fmtResolverShape(v) helper rendering the 9 V3 shapes as plain English:
- {$var: 'p'} \u2192 'p'
- {ctx-self-id: null} \u2192 'this piece'
- {ctx-self-marker-id: null} \u2192 'this marker'
- {ctx-attr: {entity, attr}} \u2192 "<entity>'s <attr>" (recursive)
- {ctx-build: {col, row}} \u2192 'e4' (algebraic if both literal) or
'the square at column <c>, row <r>'
- {add: [a, b]} \u2192 '<a> + <b>' (recursive)
- {sub/mul/mod: [a, b]} \u2192 same pattern, '-' / '\u00d7' / 'mod'
Added fmtSquareValue(v) helper for square-typed fields. fmtPieceTarget
extended to call fmtResolverShape before falling back to String.
Updated existing narrators to use fmtSquareValue for square fields:
- spawn-marker.square, spawn-marker-pair.{squareA,squareB}
- move-piece.to, place-piece.square, must-class.square
- destroy-marker.target now routes through fmtResolverShape
Test surface:
- narrate.test.ts: 69 \u2192 90 tests (+21)
- V3 resolver shapes describe block: 8 tests
- thressgame-100 primitives describe block: 12 tests
- tpl-treasure-chest end-to-end regression test: 1 test
asserts the user-reported failure is fixed (no '[object Object]',
no 'unknown primitive', expected English phrases present)
bun run check: 3291 tests pass (was 3270, +21). 0 regressions.
USER-REPORTED CASE NOW RENDERS:
Before: 'When this piece captures: spawn a treasure marker on <square>
(permanent). When a piece enters a treasure marker: unknown
primitive: add-resource; destroy [object Object].'
After: 'When this piece captures: spawn a treasure marker on this
piece's Position (permanent). When a piece enters a treasure
marker: give white +5 score; destroy this marker.'
Spot-check on 5 recipes (all render sensible English):
- tpl-treasure-chest \u2713 (see above)
- tpl-time-bomb \u2713
- tpl-down-with-the-ship \u2713
- tpl-summoning-ritual \u2713
- tpl-bouncing-ricochet \u2713
CALLOUT FOR FUTURE EPICS (recorded in learnings.md):
narrate.ts has zero engine/registry imports by design. When a new primitive
or resolver shape lands, the narrator must be updated MANUALLY \u2014 there
is no static link to detect drift. Add a registry-count-style sentinel
test in a follow-up to catch this earlier (e.g. compare KIND_NARRATORS
keys against PRIMITIVE_REGISTRY.list()).
This commit is contained in:
parent
db11a8f2d6
commit
a5ee07dead
4 changed files with 646 additions and 15 deletions
|
|
@ -152,7 +152,8 @@
|
|||
"ses_22eb3422affe0WXwp2RUpnBMbi",
|
||||
"ses_22eb4c13effeq1D5R0Hv64kDKi",
|
||||
"ses_22eb45670ffeTp5TTjGXMPI7SC",
|
||||
"ses_22eb3dab2ffeuR0mQzUofl5SpW"
|
||||
"ses_22eb3dab2ffeuR0mQzUofl5SpW",
|
||||
"ses_22ea014d4ffesgY7jcuHeSi25L"
|
||||
],
|
||||
"plan_name": "thressgame-coverage",
|
||||
"agent": "atlas"
|
||||
|
|
|
|||
|
|
@ -522,3 +522,46 @@ Final recipe count: **54**. None of the 8 W3 recipes was skipped.
|
|||
- **MODIFIED** `packages/chess/src/ui/CustomModifierEditor.tsx` — Templates modal `.map()` now branches on `recipe.id.startsWith("tpl-preset-")` for badge + border styling.
|
||||
- **CREATED** `packages/chess/docs/THRESSGAME_WONT_FIX.md` — 130-line WONT_FIX manifest.
|
||||
- **MODIFIED** `.sisyphus/notepads/thressgame-100/learnings.md` — this section.
|
||||
|
||||
---
|
||||
|
||||
## narrate.ts fix (post-W5) — V3 resolver shapes + 9 missing narrators
|
||||
|
||||
**Problem**: Custom Modifier Editor live-preview pane rendered "unknown
|
||||
primitive: add-resource" and "destroy [object Object]" for any
|
||||
descriptor using W2/W4/W5 primitives or V3 resolver shapes
|
||||
(`{$var}`, `{ctx-self-id}`, `{ctx-self-marker-id}`, `{ctx-attr}`,
|
||||
`{ctx-build}`, `{add|sub|mul|mod}`).
|
||||
|
||||
**Fix** (`packages/chess/src/ui/narrate.ts`):
|
||||
1. New `fmtResolverShape(v)` helper — recursively renders the 9 V3
|
||||
shapes mirrored from `param-resolver.ts:139-216`. Returns `null`
|
||||
for non-shapes so callers fall back to literal handling.
|
||||
2. New `fmtSquareValue(v)` helper for fields holding a square
|
||||
(literal 0-63 or resolver shape).
|
||||
3. `fmtPieceTarget` extended to call `fmtResolverShape` before its
|
||||
`String(t)` fallback.
|
||||
4. 9 new narrators added to `KIND_NARRATORS`: `add-resource`,
|
||||
`spend-resource`, `on-resource-changed`, `set-board-topology`,
|
||||
`link-pieces`, `unlink-pieces`, `on-piece-pair-link-broken`,
|
||||
`on-attr-expire`, `decrement-attr-each-turn`.
|
||||
5. Existing narrators routed through `fmtSquareValue` (spawn-marker,
|
||||
spawn-marker-pair, move-piece.to, place-piece, must-class.square)
|
||||
and `destroy-marker` switched from `String(t)` to
|
||||
`fmtResolverShape` fallback.
|
||||
|
||||
**Tests**: 21 new tests added (69 → 90), including a tpl-treasure-chest
|
||||
end-to-end test that pins the regression. All 3291 chess tests pass;
|
||||
`bun run check` exits 0.
|
||||
|
||||
**Inheritable wisdom**:
|
||||
- The `narrate.ts` zero-imports rule held — the resolver-shape
|
||||
recogniser is structurally pattern-matched, no shared module pulled
|
||||
in. The shape contract (`exactly 1 key`, key name from a fixed set)
|
||||
is duplicated between `param-resolver.ts` and `narrate.ts`; if the
|
||||
V3 union ever grows (e.g. `eq` resolver), BOTH files need the new
|
||||
case. Tag any new resolver shape's PR with a checklist item to
|
||||
update narrate.
|
||||
- The pre-existing `joinParts` quirk that produces `..` on nested
|
||||
triggers ending in `.` (see test line 865) is unchanged. Out of
|
||||
scope for this fix.
|
||||
|
|
|
|||
|
|
@ -60,7 +60,16 @@ type ExtKind =
|
|||
| "set-moves-as"
|
||||
| "set-moves-also-as"
|
||||
| "pawn-pushes-pieces"
|
||||
| "request-choice";
|
||||
| "request-choice"
|
||||
| "on-attr-expire"
|
||||
| "decrement-attr-each-turn"
|
||||
| "set-board-topology"
|
||||
| "link-pieces"
|
||||
| "unlink-pieces"
|
||||
| "on-piece-pair-link-broken"
|
||||
| "add-resource"
|
||||
| "spend-resource"
|
||||
| "on-resource-changed";
|
||||
|
||||
function extNode(kind: ExtKind, params: unknown): EffectPrimitiveNode {
|
||||
// Structural cast: node shape is identical; kind union is the only
|
||||
|
|
@ -945,6 +954,336 @@ describe("narrate — descriptor wrapper", () => {
|
|||
// Performance
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// V3 resolver shapes — `{$var}`, `{ctx-self-id}`, `{ctx-self-marker-id}`,
|
||||
// `{ctx-attr}`, `{ctx-build}`, `{add|sub|mul|mod}`. Anything that holds
|
||||
// a piece-target / square / number may now arrive as one of these
|
||||
// shapes; the narrator must render readable English instead of falling
|
||||
// back to `[object Object]`.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("narrateNodes — V3 resolver shapes", () => {
|
||||
it("$var inside destroy-piece renders the binding name", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("destroy-piece", { target: { $var: "p" } })]),
|
||||
).toBe("destroy the piece at p");
|
||||
});
|
||||
|
||||
it("ctx-self-id and ctx-self-marker-id render readable phrases", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("destroy-piece", { target: { "ctx-self-id": null } }),
|
||||
]),
|
||||
).toBe("destroy the piece at this piece");
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("destroy-marker", {
|
||||
target: { "ctx-self-marker-id": null },
|
||||
}),
|
||||
]),
|
||||
).toBe("destroy this marker");
|
||||
});
|
||||
|
||||
it("ctx-attr inside spawn-marker.square renders entity-and-attr prose", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("spawn-marker", {
|
||||
markerKind: "treasure",
|
||||
square: { "ctx-attr": { entity: "self", attr: "Position" } },
|
||||
lifetime: { kind: "permanent" },
|
||||
}),
|
||||
]);
|
||||
expect(out).toBe(
|
||||
"spawn a treasure marker on this piece's Position (permanent)",
|
||||
);
|
||||
expect(out).not.toContain("[object Object]");
|
||||
});
|
||||
|
||||
it("ctx-build with literal col+row renders algebraic square", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("spawn-marker", {
|
||||
markerKind: "mine",
|
||||
square: { "ctx-build": { col: 4, row: 3 } },
|
||||
lifetime: { kind: "permanent" },
|
||||
}),
|
||||
]),
|
||||
).toBe("spawn a mine marker on e4 (permanent)");
|
||||
});
|
||||
|
||||
it("ctx-build with resolver col renders descriptive prose", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("spawn-marker", {
|
||||
markerKind: "mine",
|
||||
square: {
|
||||
"ctx-build": { col: { $var: "c" }, row: 3 },
|
||||
},
|
||||
lifetime: { kind: "permanent" },
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("column c");
|
||||
expect(out).toContain("row 3");
|
||||
expect(out).not.toContain("[object Object]");
|
||||
});
|
||||
|
||||
it("arithmetic shapes (add/sub/mul/mod) render as prose inside ctx-build", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("spawn-marker", {
|
||||
markerKind: "mine",
|
||||
square: {
|
||||
"ctx-build": {
|
||||
col: { add: [{ $var: "col" }, 1] },
|
||||
row: { sub: [{ $var: "row" }, 2] },
|
||||
},
|
||||
},
|
||||
lifetime: { kind: "permanent" },
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("col + 1");
|
||||
expect(out).toContain("row - 2");
|
||||
expect(out).not.toContain("[object Object]");
|
||||
});
|
||||
|
||||
it("mul and mod render correctly", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("spend-resource", {
|
||||
player: "white",
|
||||
amount: { mul: [2, { mod: [{ $var: "n" }, 4] }] },
|
||||
then: [extNode("cancel-capture", {})],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("2 × ");
|
||||
expect(out).toContain("n mod 4");
|
||||
expect(out).not.toContain("[object Object]");
|
||||
});
|
||||
|
||||
it("destroy-marker with ctx-self-marker-id no longer renders [object Object]", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("destroy-marker", {
|
||||
target: { "ctx-self-marker-id": null },
|
||||
}),
|
||||
]),
|
||||
).toBe("destroy this marker");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// thressgame-100 W2 / W4 / W5 narrators
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("narrateNodes — thressgame-100 primitives", () => {
|
||||
it("on-attr-expire renders attr name and target", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("on-attr-expire", {
|
||||
target: { "ctx-self-id": null },
|
||||
attr: "BombCountdown",
|
||||
primitives: [
|
||||
extNode("destroy-piece", { target: "self" }),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe(
|
||||
"When the BombCountdown attr on this piece expires: destroy the piece at self.",
|
||||
);
|
||||
});
|
||||
|
||||
it("decrement-attr-each-turn renders attr and target", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("decrement-attr-each-turn", {
|
||||
target: { "ctx-self-id": null },
|
||||
attr: "BombCountdown",
|
||||
}),
|
||||
]),
|
||||
).toBe("each turn-end, subtract 1 from BombCountdown on this piece");
|
||||
});
|
||||
|
||||
it("set-board-topology renders the value", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("set-board-topology", { value: "wrap-files" }),
|
||||
]),
|
||||
).toBe("set board topology to wrap-files");
|
||||
});
|
||||
|
||||
it("link-pieces names both pieces", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("link-pieces", { a: 4, b: 7 })]),
|
||||
).toBe("link the pieces at e1 and h1");
|
||||
});
|
||||
|
||||
it("unlink-pieces names both pieces", () => {
|
||||
expect(
|
||||
narrateNodes([extNode("unlink-pieces", { a: 4, b: 7 })]),
|
||||
).toBe("unlink the pieces at e1 and h1");
|
||||
});
|
||||
|
||||
it("on-piece-pair-link-broken renders surviving-partner trigger", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("on-piece-pair-link-broken", {
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: 1 } },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe("When a linked partner is destroyed: add 1 to Hp.");
|
||||
});
|
||||
|
||||
it("add-resource with positive amount renders give-form", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("add-resource", { player: "white", amount: 5 }),
|
||||
]),
|
||||
).toBe("give white +5 score");
|
||||
});
|
||||
|
||||
it("add-resource with negative amount renders subtract-form", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("add-resource", { player: "black", amount: -3 }),
|
||||
]),
|
||||
).toBe("subtract 3 from black's score");
|
||||
});
|
||||
|
||||
it("spend-resource with then-only branch", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("spend-resource", {
|
||||
player: "white",
|
||||
amount: 5,
|
||||
then: [
|
||||
extNode("place-piece", {
|
||||
pieceType: "knight",
|
||||
color: "white",
|
||||
square: 28,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe(
|
||||
"If white has 5 score, spend it, then: place a white knight on e4.",
|
||||
);
|
||||
});
|
||||
|
||||
it("spend-resource with both branches", () => {
|
||||
const out = narrateNodes([
|
||||
extNode("spend-resource", {
|
||||
player: "white",
|
||||
amount: 3,
|
||||
then: [extNode("cancel-capture", {})],
|
||||
else: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("If white has 3 score, spend it, then:");
|
||||
expect(out).toContain("otherwise: subtract 1 from Hp");
|
||||
});
|
||||
|
||||
it("on-resource-changed up renders crossing-up trigger", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("on-resource-changed", {
|
||||
player: "white",
|
||||
threshold: 10,
|
||||
direction: "up",
|
||||
primitives: [
|
||||
extNode("place-piece", {
|
||||
pieceType: "queen",
|
||||
color: "white",
|
||||
square: 28,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe(
|
||||
"When white's score crosses 10 going up: place a white queen on e4.",
|
||||
);
|
||||
});
|
||||
|
||||
it("on-resource-changed down renders crossing-down trigger", () => {
|
||||
expect(
|
||||
narrateNodes([
|
||||
extNode("on-resource-changed", {
|
||||
player: "black",
|
||||
threshold: 0,
|
||||
direction: "down",
|
||||
primitives: [
|
||||
{ kind: "add-to-attribute", params: { attr: "Hp", delta: -1 } },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
).toBe(
|
||||
"When black's score crosses 0 going down: subtract 1 from Hp.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// End-to-end regression: tpl-treasure-chest descriptor (the user's
|
||||
// failing case). Mirrors recipes.ts shape exactly.
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("narrate — tpl-treasure-chest end-to-end", () => {
|
||||
it("renders cleanly with no [object Object] and no unknown-primitive", () => {
|
||||
const descriptor: CustomModifierDescriptor = {
|
||||
type: "data",
|
||||
id: "tpl-treasure-chest" as CustomModifierDescriptor["id"],
|
||||
name: "Treasure Chest",
|
||||
description: "",
|
||||
version: 1,
|
||||
primitives: [
|
||||
{
|
||||
kind: "on-capture",
|
||||
params: {
|
||||
primitives: [
|
||||
{
|
||||
kind: "spawn-marker",
|
||||
params: {
|
||||
markerKind: "treasure",
|
||||
square: {
|
||||
"ctx-attr": { entity: "self", attr: "Position" },
|
||||
},
|
||||
lifetime: { kind: "permanent" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "on-piece-entered-marker",
|
||||
params: {
|
||||
markerKind: "treasure",
|
||||
primitives: [
|
||||
{
|
||||
kind: "add-resource",
|
||||
params: { player: "white", amount: 5 },
|
||||
},
|
||||
{
|
||||
kind: "destroy-marker",
|
||||
params: { target: { "ctx-self-marker-id": null } },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
] as unknown as EffectPrimitiveNode[],
|
||||
targetAttrs: [],
|
||||
uiForm: "primitive-composer",
|
||||
source: "custom",
|
||||
};
|
||||
const out = narrate(descriptor);
|
||||
expect(out).not.toContain("[object Object]");
|
||||
expect(out).not.toContain("unknown primitive");
|
||||
expect(out).toContain("When this piece captures");
|
||||
expect(out).toContain("spawn a treasure marker on this piece's Position");
|
||||
expect(out).toContain("When a piece enters a treasure marker");
|
||||
expect(out).toContain("give white +5 score");
|
||||
expect(out).toContain("destroy this marker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("narrate — performance", () => {
|
||||
it("50-primitive descriptor runs in < 1ms averaged over 100 runs", () => {
|
||||
// Build a realistic 50-node descriptor: mix of leaf primitives and
|
||||
|
|
|
|||
|
|
@ -122,6 +122,104 @@ function fmtCondition(c: unknown): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a V3 resolver shape (`{$var}`, `{ctx-self-id}`,
|
||||
* `{ctx-self-marker-id}`, `{ctx-attr}`, `{ctx-build}`,
|
||||
* `{add|sub|mul|mod}`) as plain English. Returns `null` when `v` is
|
||||
* not a recognised resolver shape — callers fall back to their own
|
||||
* literal handling.
|
||||
*
|
||||
* Mirrors the canonical V3 union in
|
||||
* `modifiers/primitives/param-resolver.ts:139-216`. Recursive: a
|
||||
* `ctx-attr.entity` may itself be a resolver shape, an `add`'s
|
||||
* operands may be resolver shapes, etc.
|
||||
*/
|
||||
function fmtResolverShape(v: unknown): string | null {
|
||||
if (v === null || typeof v !== "object") return null;
|
||||
const obj = v as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length !== 1) return null;
|
||||
const key = keys[0]!;
|
||||
|
||||
switch (key) {
|
||||
case "$var": {
|
||||
const name = obj.$var;
|
||||
if (typeof name !== "string") return null;
|
||||
return name;
|
||||
}
|
||||
case "ctx-self-id":
|
||||
return "this piece";
|
||||
case "ctx-self-marker-id":
|
||||
return "this marker";
|
||||
case "ctx-attr": {
|
||||
const inner = obj["ctx-attr"];
|
||||
if (inner === null || typeof inner !== "object") return null;
|
||||
const { entity, attr } = inner as {
|
||||
entity?: unknown;
|
||||
attr?: unknown;
|
||||
};
|
||||
if (typeof attr !== "string") return null;
|
||||
const entStr =
|
||||
fmtResolverShape(entity) ??
|
||||
(entity === "self"
|
||||
? "this piece"
|
||||
: entity === "chooser"
|
||||
? "the chooser"
|
||||
: typeof entity === "number"
|
||||
? `piece #${entity.toString(10)}`
|
||||
: "?");
|
||||
return `${entStr}'s ${attr}`;
|
||||
}
|
||||
case "ctx-build": {
|
||||
const inner = obj["ctx-build"];
|
||||
if (inner === null || typeof inner !== "object") return null;
|
||||
const { col, row } = inner as { col?: unknown; row?: unknown };
|
||||
if (
|
||||
typeof col === "number" &&
|
||||
typeof row === "number" &&
|
||||
Number.isInteger(col) &&
|
||||
Number.isInteger(row) &&
|
||||
col >= 0 &&
|
||||
col <= 7 &&
|
||||
row >= 0 &&
|
||||
row <= 7
|
||||
) {
|
||||
return fmtSquare(col + row * 8);
|
||||
}
|
||||
const colStr =
|
||||
typeof col === "number"
|
||||
? fmtNumber(col)
|
||||
: (fmtResolverShape(col) ?? "?");
|
||||
const rowStr =
|
||||
typeof row === "number"
|
||||
? fmtNumber(row)
|
||||
: (fmtResolverShape(row) ?? "?");
|
||||
return `the square at column ${colStr}, row ${rowStr}`;
|
||||
}
|
||||
case "add":
|
||||
case "sub":
|
||||
case "mul":
|
||||
case "mod": {
|
||||
const arr = obj[key];
|
||||
if (!Array.isArray(arr) || arr.length !== 2) return null;
|
||||
const [a, b] = arr;
|
||||
const aStr =
|
||||
typeof a === "number"
|
||||
? fmtNumber(a)
|
||||
: (fmtResolverShape(a) ?? "?");
|
||||
const bStr =
|
||||
typeof b === "number"
|
||||
? fmtNumber(b)
|
||||
: (fmtResolverShape(b) ?? "?");
|
||||
if (key === "mod") return `${aStr} mod ${bStr}`;
|
||||
const sym = key === "add" ? "+" : key === "sub" ? "-" : "×";
|
||||
return `${aStr} ${sym} ${bStr}`;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtPieceTarget(t: unknown): string {
|
||||
if (t === "self") return "self";
|
||||
if (typeof t === "number" && Number.isInteger(t)) {
|
||||
|
|
@ -129,9 +227,25 @@ function fmtPieceTarget(t: unknown): string {
|
|||
return `piece #${t.toString(10)}`;
|
||||
}
|
||||
if (t === undefined || t === null) return "self";
|
||||
const shape = fmtResolverShape(t);
|
||||
if (shape !== null) return shape;
|
||||
return String(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value that holds a SQUARE (0-63 literal, or a V3 resolver
|
||||
* shape that resolves to one). Falls back to "<square>" when the
|
||||
* value is neither a literal square nor a known resolver shape.
|
||||
*/
|
||||
function fmtSquareValue(v: unknown): string {
|
||||
if (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 63) {
|
||||
return fmtSquare(v);
|
||||
}
|
||||
const shape = fmtResolverShape(v);
|
||||
if (shape !== null) return shape;
|
||||
return "<square>";
|
||||
}
|
||||
|
||||
function fmtLifetime(lt: unknown): string {
|
||||
if (lt === undefined) return "permanent";
|
||||
if (lt === "permanent") return "permanent";
|
||||
|
|
@ -429,8 +543,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
| undefined;
|
||||
const color = typeof p?.color === "string" ? p.color : "<color>";
|
||||
const pt = typeof p?.pieceType === "string" ? p.pieceType : "<pieceType>";
|
||||
const sq =
|
||||
typeof p?.square === "number" ? fmtSquare(p.square) : "<square>";
|
||||
const sq = fmtSquareValue(p?.square);
|
||||
return `place a ${color} ${pt} on ${sq}`;
|
||||
},
|
||||
"destroy-piece": (params) => {
|
||||
|
|
@ -440,7 +553,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
"move-piece": (params) => {
|
||||
const p = params as { target?: unknown; to?: unknown } | null | undefined;
|
||||
const from = fmtPieceTarget(p?.target);
|
||||
const to = typeof p?.to === "number" ? fmtSquare(p.to) : "<to>";
|
||||
const to = fmtSquareValue(p?.to);
|
||||
return `move the piece at ${from} to ${to}`;
|
||||
},
|
||||
"swap-pieces": (params) => {
|
||||
|
|
@ -497,8 +610,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
| null
|
||||
| undefined;
|
||||
const mk = typeof p?.markerKind === "string" ? p.markerKind : "<markerKind>";
|
||||
const sq =
|
||||
typeof p?.square === "number" ? fmtSquare(p.square) : "<square>";
|
||||
const sq = fmtSquareValue(p?.square);
|
||||
const lt = fmtLifetime(p?.lifetime);
|
||||
const owner =
|
||||
typeof p?.owner === "string" ? ` owned by ${p.owner}` : "";
|
||||
|
|
@ -516,10 +628,8 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
| null
|
||||
| undefined;
|
||||
const mk = typeof p?.markerKind === "string" ? p.markerKind : "<markerKind>";
|
||||
const a =
|
||||
typeof p?.squareA === "number" ? fmtSquare(p.squareA) : "<a>";
|
||||
const b =
|
||||
typeof p?.squareB === "number" ? fmtSquare(p.squareB) : "<b>";
|
||||
const a = fmtSquareValue(p?.squareA);
|
||||
const b = fmtSquareValue(p?.squareB);
|
||||
const lt = fmtLifetime(p?.lifetime);
|
||||
const owner =
|
||||
typeof p?.owner === "string" ? ` owned by ${p.owner}` : "";
|
||||
|
|
@ -528,8 +638,10 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
"destroy-marker": (params) => {
|
||||
const p = params as { target?: unknown } | null | undefined;
|
||||
const t = p?.target;
|
||||
const tStr = typeof t === "number" ? `marker #${t.toString(10)}` : String(t ?? "<target>");
|
||||
return `destroy ${tStr}`;
|
||||
if (typeof t === "number") return `destroy marker #${t.toString(10)}`;
|
||||
const shape = fmtResolverShape(t);
|
||||
if (shape !== null) return `destroy ${shape}`;
|
||||
return `destroy ${String(t ?? "<target>")}`;
|
||||
},
|
||||
"for-each-piece": (params, ctx) => {
|
||||
const p = params as
|
||||
|
|
@ -689,8 +801,7 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
| undefined;
|
||||
const cls = typeof p?.class === "string" ? p.class : "<class>";
|
||||
if (cls === "move-to") {
|
||||
const sq =
|
||||
typeof p?.square === "number" ? fmtSquare(p.square) : "<square>";
|
||||
const sq = fmtSquareValue(p?.square);
|
||||
return `force the next move to land on ${sq}`;
|
||||
}
|
||||
if (cls === "capture") return "force the next move to be a capture";
|
||||
|
|
@ -753,6 +864,143 @@ const KIND_NARRATORS: Record<string, Narrator> = {
|
|||
const promptStr = prompt.length === 0 ? "" : ` "${prompt}"`;
|
||||
return `Ask ${forPlayer} for a ${pickerKind} choice${promptStr} (bind as ${bind}), then: ${narrateChildrenClause(children, ctx)}.`;
|
||||
},
|
||||
|
||||
// ── thressgame-100 W2 / W4 / W5 primitives ──────────────────────
|
||||
|
||||
"on-attr-expire": (params, ctx) => {
|
||||
const p = params as
|
||||
| { target?: unknown; attr?: unknown; primitives?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
const target = fmtPieceTarget(p?.target);
|
||||
const attr = typeof p?.attr === "string" ? p.attr : "<attr>";
|
||||
const children = Array.isArray(p?.primitives)
|
||||
? (p.primitives as readonly EffectPrimitiveNode[])
|
||||
: [];
|
||||
return `When the ${attr} attr on ${target} expires: ${narrateChildrenClause(children, ctx)}.`;
|
||||
},
|
||||
|
||||
"decrement-attr-each-turn": (params) => {
|
||||
const p = params as
|
||||
| { target?: unknown; attr?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
const target = fmtPieceTarget(p?.target);
|
||||
const attr = typeof p?.attr === "string" ? p.attr : "<attr>";
|
||||
return `each turn-end, subtract 1 from ${attr} on ${target}`;
|
||||
},
|
||||
|
||||
"set-board-topology": (params) => {
|
||||
const p = params as { value?: unknown } | null | undefined;
|
||||
const value = typeof p?.value === "string" ? p.value : "<value>";
|
||||
return `set board topology to ${value}`;
|
||||
},
|
||||
|
||||
"link-pieces": (params) => {
|
||||
const p = params as { a?: unknown; b?: unknown } | null | undefined;
|
||||
const a = fmtPieceTarget(p?.a);
|
||||
const b = fmtPieceTarget(p?.b);
|
||||
return `link the pieces at ${a} and ${b}`;
|
||||
},
|
||||
|
||||
"unlink-pieces": (params) => {
|
||||
const p = params as { a?: unknown; b?: unknown } | null | undefined;
|
||||
const a = fmtPieceTarget(p?.a);
|
||||
const b = fmtPieceTarget(p?.b);
|
||||
return `unlink the pieces at ${a} and ${b}`;
|
||||
},
|
||||
|
||||
"on-piece-pair-link-broken": (params, ctx) => {
|
||||
const p = params as { primitives?: unknown } | null | undefined;
|
||||
const children = Array.isArray(p?.primitives)
|
||||
? (p.primitives as readonly EffectPrimitiveNode[])
|
||||
: [];
|
||||
return `When a linked partner is destroyed: ${narrateChildrenClause(children, ctx)}.`;
|
||||
},
|
||||
|
||||
"add-resource": (params) => {
|
||||
const p = params as
|
||||
| { player?: unknown; amount?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
const playerStr =
|
||||
typeof p?.player === "string"
|
||||
? p.player
|
||||
: (fmtResolverShape(p?.player) ?? "<player>");
|
||||
const amountRaw = p?.amount;
|
||||
if (typeof amountRaw === "number") {
|
||||
if (amountRaw < 0) {
|
||||
return `subtract ${fmtNumber(Math.abs(amountRaw))} from ${playerStr}'s score`;
|
||||
}
|
||||
return `give ${playerStr} +${fmtNumber(amountRaw)} score`;
|
||||
}
|
||||
const amountStr = fmtResolverShape(amountRaw) ?? "<amount>";
|
||||
return `give ${playerStr} +${amountStr} score`;
|
||||
},
|
||||
|
||||
"spend-resource": (params, ctx) => {
|
||||
const p = params as
|
||||
| {
|
||||
player?: unknown;
|
||||
amount?: unknown;
|
||||
then?: unknown;
|
||||
else?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const playerStr =
|
||||
typeof p?.player === "string"
|
||||
? p.player
|
||||
: (fmtResolverShape(p?.player) ?? "<player>");
|
||||
const amountStr =
|
||||
typeof p?.amount === "number"
|
||||
? fmtNumber(p.amount)
|
||||
: (fmtResolverShape(p?.amount) ?? "<amount>");
|
||||
const thenArr = Array.isArray(p?.then)
|
||||
? (p.then as readonly EffectPrimitiveNode[])
|
||||
: [];
|
||||
const elseArr = Array.isArray(p?.else)
|
||||
? (p.else as readonly EffectPrimitiveNode[])
|
||||
: undefined;
|
||||
const thenStr = narrateChildrenClause(thenArr, ctx);
|
||||
if (elseArr !== undefined) {
|
||||
const elseStr = narrateChildrenClause(elseArr, ctx);
|
||||
return `If ${playerStr} has ${amountStr} score, spend it, then: ${thenStr}; otherwise: ${elseStr}.`;
|
||||
}
|
||||
return `If ${playerStr} has ${amountStr} score, spend it, then: ${thenStr}.`;
|
||||
},
|
||||
|
||||
"on-resource-changed": (params, ctx) => {
|
||||
const p = params as
|
||||
| {
|
||||
player?: unknown;
|
||||
threshold?: unknown;
|
||||
direction?: unknown;
|
||||
primitives?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const playerStr =
|
||||
typeof p?.player === "string"
|
||||
? p.player
|
||||
: (fmtResolverShape(p?.player) ?? "<player>");
|
||||
const thresholdStr =
|
||||
typeof p?.threshold === "number"
|
||||
? fmtNumber(p.threshold)
|
||||
: (fmtResolverShape(p?.threshold) ?? "<threshold>");
|
||||
const direction =
|
||||
typeof p?.direction === "string" ? p.direction : "any";
|
||||
const dirPhrase =
|
||||
direction === "up"
|
||||
? "going up"
|
||||
: direction === "down"
|
||||
? "going down"
|
||||
: "in either direction";
|
||||
const children = Array.isArray(p?.primitives)
|
||||
? (p.primitives as readonly EffectPrimitiveNode[])
|
||||
: [];
|
||||
return `When ${playerStr}'s score crosses ${thresholdStr} ${dirPhrase}: ${narrateChildrenClause(children, ctx)}.`;
|
||||
},
|
||||
};
|
||||
|
||||
function defaultNarrator(node: EffectPrimitiveNode): string {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue