/**
* Two-pass mechanics — Pass 2, "the accountant" (Phase G2).
*
* Pass 1 narrates the scene at high temperature with a plain `respond()` and
* never touches a number. Pass 2 then reads that finished prose and emits ONE
* schema-forced structured object describing the ledger changes it implies — the
* resource deltas that happened, any new foe, and (Lot 2) a mechanical tag for
* each numbered option offered. Because only Pass 2 writes hard numbers there is
* exactly one writer → no double counting, and because the object is a *forced*
* structured generation (a grammar, not an optional tool call) the "model forgot
* to record it" failure mode is gone. This works on any real `LLM` token source;
* it does NOT require a tool-trained model (decision 7).
*
* Pure and unit-testable, like the rest of `rules/`: this file builds the schema,
* the prompt and the pure application of a parsed object onto the engine. The one
* impure boundary — the `respond({ structured })` call — lives in the handler,
* exactly as sheet generation (`sheetgen.ts`) does. No `@lmstudio/sdk` import.
*
* See `docs/phase-g2-two-pass-mechanics.md` for the decision record.
*/
import { z } from "zod";
import { Delta, Move, PendingChoice, Sheet } from "../state/schema.js";
import { Rng } from "./dice.js";
import { applyEffects, ResolvedEffect } from "./resolve.js";
import { RulesDefinition } from "./schema.js";
import { applySpawns, SpawnRequest, tidyRoster } from "./spawn.js";
/** A zod enum over the keys, or a plain string when there are none (never empty). */
export function keyEnum(keys: string[]): z.ZodTypeAny {
return keys.length > 0 ? z.enum(keys as [string, ...string[]]) : z.string();
}
/** Format a signed integer as a delta expression the engine understands. */
export function signedExpr(amount: number): string {
return amount >= 0 ? `+${amount}` : `${amount}`;
}
/** Bounded difficulty tiers the narrator judges; mapped to a flat roll bonus. */
export const DIFFICULTY_VALUES = ["trivial", "risky", "dangerous", "desperate"] as const;
/**
* Build the Zod schema the accountant's reply is forced into, with keys (the
* resource and archetype enums) derived from the universe rules so the model can
* never name something the rules don't declare. Fixed shapes are preferred over
* `optional()` — constrained grammars are far more reliable on them. The `spawns`
* and `departed` arrays are present only when the universe declares spawnable
* archetypes (no archetypes ⇒ no combatants ⇒ nothing to spawn or remove).
*
* Fields: `effects` (always), `spawns` + `departed` (when archetypes exist), and
* `options` (always) — one tag per numbered option the narration offered.
*/
export function buildExtractionSchema(rules: RulesDefinition): z.ZodTypeAny {
const resourceKeys = Object.keys(rules.resources);
const statKeys = Object.keys(rules.stats);
const archetypeKeys = Object.keys(rules.archetypes).filter((a) => a !== "_default");
const Effect = z.object({
target: z.string(),
resource: keyEnum(resourceKeys),
amount: z.number().int(),
});
// Fixed shape with a `kind` discriminator (more grammar-reliable than unions):
// `stat`/`difficulty` matter only for "risky", `resource`/`amount` only for
// "cost"; the others are ignored but must still be present.
const Option = z.object({
index: z.number().int(),
kind: z.enum(["plain", "risky", "cost"]),
stat: keyEnum(statKeys),
difficulty: z.enum(DIFFICULTY_VALUES),
// Opposition (Phase G — opposition rolls), meaningful only for a "risky"
// option contested by a creature. `opposedBy` is the LIVE enemy id (a free
// string, validated by the engine like `Delta.target`; "" = unopposed);
// `opposingStat` is the stat that foe resists with. Always present.
opposedBy: z.string(),
opposingStat: keyEnum(statKeys),
resource: keyEnum(resourceKeys),
amount: z.number().int(),
});
const shape: Record<string, z.ZodTypeAny> = {
effects: z.array(Effect),
};
if (archetypeKeys.length > 0) {
const Spawn = z.object({
id: z.string(),
archetype: keyEnum(archetypeKeys),
label: z.string(),
});
shape.spawns = z.array(Spawn);
// Ids of foes the scene removed WITHOUT a kill (fled, surrendered, talked
// down, or left behind by a scene/time change). Free strings, validated by
// the engine against the live roster like a stale `Delta.target`.
shape.departed = z.array(z.string());
}
shape.options = z.array(Option);
return z.object(shape);
}
/** Stable example keys drawn from the rules, so few-shot JSON uses real keys. */
function exampleKeys(rules: RulesDefinition): {
vital: string;
spend: string;
stat1: string;
stat2: string;
archetype: string;
archetypeLabel: string;
} {
const resKeys = Object.keys(rules.resources);
const statKeys = Object.keys(rules.stats);
const archKeys = Object.keys(rules.archetypes).filter((a) => a !== "_default");
const vital = resKeys.find((k) => rules.resources[k].endWhenZero) ?? resKeys[0] ?? "hp";
const spend = resKeys.find((k) => !rules.resources[k].endWhenZero) ?? resKeys[0] ?? "coin";
const stat1 = statKeys[0] ?? "";
const stat2 = statKeys[1] ?? statKeys[0] ?? "";
const archetype = archKeys[0] ?? "";
const archetypeLabel = archetype ? rules.archetypes[archetype].label || archetype : "";
return { vital, spend, stat1, stat2, archetype, archetypeLabel };
}
/**
* Build the accountant's prompt: a CONSTANT, cache-friendly system prefix (role
* + field rules + few-shot examples, byte-identical every turn for a given
* universe), and a variable user tail — the current `# Status`, the player's
* action this turn, then the narration that resulted, each under its own
* markdown header. The tail sits AFTER the prefix so the prefix's KV cache
* survives turns. The player's action is included because it is sometimes more
* precise than the prose (the player says "3 coins", the narrator writes "a few
* coins"); the accountant trusts the player's number to keep the ledger exact.
*/
export function buildExtractionPrompt(
rules: RulesDefinition,
statusText: string,
playerAction: string,
narration: string,
/**
* The referee's consequence forecast (Phase G3): resource keys it judged this
* free-form action puts in play. A CHECKLIST so the accountant doesn't miss a
* change the narration depicts but states tersely — never a mandate to invent.
* Lives in the variable user tail (not the cached system prefix), so it is
* absent on pick turns / adjudication-off → the prompt stays byte-identical.
*/
forecast: string[] = [],
/**
* Pending bargains from the social referee (Phase J): for each NPC whose stance
* was `negotiate`/`hesitant`, a one-line "<NPC> is moved only by: <wants>". The
* negotiation hook — so if the narration shows the player accepting and PAYING a
* price that maps to a tracked resource, the accountant records the cost rather
* than missing a deal the prose states tersely. Like `forecast`: a CHECKLIST,
* never a mandate; in the variable tail; empty → byte-identical.
*/
bargains: string[] = [],
/**
* The resource changes the ENGINE already applied this turn (the resolved pick's
* certain cost, or a roll's deltas) — already reflected in `# Status`. Rendered
* as a "do NOT record these again" block so the accountant doesn't re-bill a
* payment the narration depicts but the engine has already charged (the live
* double-charge bug: a pick's −2 cost applied, then the prose's payment re-read
* as a further −3 → −5 total). In the variable tail; empty → byte-identical.
*/
applied: ResolvedEffect[] = [],
): { system: string; user: string } {
const resourceKeys = Object.keys(rules.resources);
const statKeys = Object.keys(rules.stats);
const archetypeKeys = Object.keys(rules.archetypes).filter((a) => a !== "_default");
const hasSpawn = archetypeKeys.length > 0;
const { vital, spend, stat1, stat2, archetype, archetypeLabel } = exampleKeys(rules);
// Build an example object in the exact schema shape (spawns + departed only
// when allowed; options always present).
const ex = (
effects: unknown[],
spawns: unknown[],
options: unknown[] = [],
departed: unknown[] = [],
): string =>
JSON.stringify(hasSpawn ? { effects, spawns, departed, options } : { effects, options });
const resourceList = resourceKeys
.map((k) => `"${k}"${rules.resources[k].label ? ` (${rules.resources[k].label})` : ""}`)
.join(", ");
const statList = statKeys
.map((k) => `"${k}"${rules.stats[k].label ? ` (${rules.stats[k].label})` : ""}`)
.join(", ");
const archetypeList = archetypeKeys
.map((k) => `"${k}"${rules.archetypes[k].label ? ` (${rules.archetypes[k].label})` : ""}`)
.join(", ");
const lines: string[] = [
"You are the mechanics accountant for a tabletop role-play game. You convert " +
"a FINISHED narration into ledger changes. You never narrate, never add to " +
"the story, and never offer choices. Output only the structured object.",
"",
"You are given the current `# Status`, the player's action this turn, and the " +
"narration that resulted. Report, as exact signed whole numbers, the resource " +
"changes that happened — and any new enemy that appeared.",
"",
"Field rules:",
"- effects[]: one entry per resource change the narration states or clearly " +
"implies. `amount` is a signed integer: negative for a loss/cost, positive " +
"for a gain. `target` is \"player\", or the id of an enemy listed under " +
"# Status.",
"- Most scenes change nothing — then `effects` is [] (an empty list). This is " +
"the normal, common case. Do NOT invent a change just to fill the list.",
"- A gain to the player is recorded ONLY when the WORLD provides it with the " +
"giver or source shown in the narration — handed over, paid, found, won, or " +
"taken from another. A resource that merely appears, or that the player " +
"themselves produces, reveals, or performs, is NOT a gain: the player is " +
"never a source of their own resources, so record nothing for it, however " +
"the scene describes the act. A loss is recorded whenever the narration " +
"shows the player paying, spending, or losing it.",
"- The player's action is often more precise than the prose: if the player " +
"said \"I give her 3 coins\" but the narration only says \"a few coins\", " +
"trust the player's number and record −3. Their stated amount wins.",
"- The game engine already applied any dice-roll outcome and any chosen " +
"option's certain cost BEFORE this scene; those are already reflected in " +
"# Status. Do NOT report them again — only NEW changes the narration adds.",
];
if (hasSpawn) {
lines.push(
"- spawns[]: one entry per character who becomes an ACTIVE PHYSICAL " +
"ADVERSARY this scene — a short stable `id`, an `archetype`, and a display " +
"`label`. This is NOT only a new enemy bursting in: a character ALREADY " +
"present (someone at the bar, by the fire, in the crowd) becomes a " +
"combatant the moment the scene turns physical against them — the player " +
"strikes, grabs, grapples, shoves, or squares off to fight them, or they " +
"do so to the player. The contest need not be lethal: a brawl, a wrestling " +
"match, or a test of strength still spawns the opponent, so their own " +
"strength resists the rolls. Pick the archetype that best fits what the " +
"character is (a hulking brute, a nimble thief). Reuse an existing id from " +
"# Status instead of re-spawning a foe already there; [] only when no one " +
"is in a physical contest with the player.",
);
lines.push(
"- departed[]: the ids of any foe currently listed under # Status that this " +
"scene has taken OUT of the fight WITHOUT killing it — it broke off and " +
"fled, surrendered or was subdued, was talked or calmed down, or the " +
"action has moved to a new place or time and left it behind. Use the " +
"exact id from # Status. A foe still present and fighting — even if only " +
"wounded, staggered, or reeling — does NOT belong here; [] whenever the " +
"same foes are still in the scene. (A foe killed in combat is removed by " +
"the engine, never reported here.)",
);
}
lines.push(
"- options[]: tag EVERY numbered option the narration offered at the end of " +
"the scene, in order — one entry each. `index` is the option's number.",
" `kind` is exactly one of:",
" - \"plain\": a narrative choice with no mechanics (the common default).",
" - \"risky\": an uncertain action resolved by a dice roll — set `stat` " +
"(which stat is tested) and `difficulty` (your judgment of how hard it is: " +
"trivial | risky | dangerous | desperate). If the option pits the player " +
"against a specific enemy listed under # Status (strike it, grapple it, " +
"deceive it), also set `opposedBy` to that enemy's id and `opposingStat` to " +
"the stat that best fits HOW THAT FOE resists (a brute overpowers with " +
"might, a nimble foe evades with grace, a cunning one outwits) — then keep " +
"`difficulty` for circumstance only (default `risky`), so a tough foe is " +
"not counted twice. Leave `opposedBy` empty when nothing living opposes the action.",
" - \"cost\": a CERTAIN resource change applied only if the player picks it " +
"— set `resource` and `amount` (signed; negative = a price, positive = a " +
"reward).",
" Fill the fields a kind ignores with any valid value (they are not used). " +
"An option is exactly ONE kind. If the narration offered no numbered " +
"options, `options` is [].",
);
lines.push("", `Resources you may use: ${resourceList || "(none)"}.`);
if (statList) lines.push(`Stats you may test: ${statList}.`);
if (hasSpawn) lines.push(`Enemy archetypes you may spawn: ${archetypeList}.`);
// Few-shot examples (3–4), including a negative one. Concrete keys come from
// the rules so the model always sees valid resource/archetype names.
lines.push("", "Examples:");
lines.push(
`Narration: "You count out a few ${rules.resources[spend].label || spend} and ` +
`slide them across the counter for the room key."`,
`JSON: ${ex([{ target: "player", resource: spend, amount: -5 }], [])}`,
"",
`Narration: "Your blade opens a deep gash across the brute's ribs; he reels back."`,
`JSON: ${ex([{ target: "brute-1", resource: vital, amount: -4 }], [])}`,
"",
`Narration: "You walk the empty road beneath a cold moon, saying nothing."`,
`JSON: ${ex([], [])}`,
);
if (hasSpawn) {
lines.push(
"",
`Narration: "Two ${archetypeLabel || archetype}s shoulder out of the alley, knives drawn."`,
`JSON: ${ex(
[],
[
{ id: `${archetype}-1`, archetype, label: archetypeLabel || archetype },
{ id: `${archetype}-2`, archetype, label: archetypeLabel || archetype },
],
)}`,
// A character ALREADY in the scene the player picks a fight with — the case
// the model otherwise misses (it reads as set-dressing, not a "new enemy").
"",
`Narration: "You seize the ${archetypeLabel || archetype} by the fire by the wrist and twist; he ` +
`surges to his feet, the bench scraping back, and the room goes quiet."`,
`JSON: ${ex([], [{ id: `${archetype}-1`, archetype, label: archetypeLabel || archetype }])}`,
// A foe (already in # Status as `${archetype}-1`) leaving the fight without a
// kill — the case `departed` exists for. The roster must drop it.
"",
`Narration: "The ${archetypeLabel || archetype} throws down his blade and bolts into the dark; ` +
`you let him go and the alley falls quiet behind you."`,
`JSON: ${ex([], [], [], [`${archetype}-1`])}`,
);
}
// The narration usually ends with numbered options — tag each by kind.
lines.push(
"",
`Narration: "The barkeep eyes you over the rail. 'Trouble follows you, friend.'\n` +
`1. Ask her what she has heard.\n` +
`2. Slip past her into the locked back room.\n` +
`3. Buy her silence with a few coins."`,
`JSON: ${ex([], [], [
{ index: 1, kind: "plain", stat: stat1, difficulty: "trivial", opposedBy: "", opposingStat: stat1, resource: spend, amount: 0 },
{ index: 2, kind: "risky", stat: stat2, difficulty: "dangerous", opposedBy: "", opposingStat: stat1, resource: spend, amount: 0 },
{ index: 3, kind: "cost", stat: stat1, difficulty: "trivial", opposedBy: "", opposingStat: stat1, resource: spend, amount: -5 },
])}`,
);
// A combat scene with a listed foe: the contested options carry opposition, the
// talk-it-down one does not. Only shown when the universe declares enemies.
if (hasSpawn) {
lines.push(
"",
`Narration: "The ${archetypeLabel || archetype} lunges at you, blade flashing.\n` +
`1. Parry and cut back.\n` +
`2. Try to talk him down."`,
`JSON: ${ex([], [], [
{ index: 1, kind: "risky", stat: stat1, difficulty: "risky", opposedBy: `${archetype}-1`, opposingStat: stat1, resource: spend, amount: 0 },
{ index: 2, kind: "risky", stat: stat2, difficulty: "risky", opposedBy: "", opposingStat: stat2, resource: spend, amount: 0 },
])}`,
);
}
const system = lines.join("\n");
const userParts = [
statusText.trim(),
"",
"# Player's action this turn",
playerAction.trim() || "(no explicit action)",
];
// The referee's forecast, as a checklist (only declared resources; omitted
// when empty so pick turns / adjudication-off are byte-identical to before).
const forecastLabels = forecast
.filter((k) => rules.resources[k])
.map((k) => rules.resources[k].label || k);
if (forecastLabels.length > 0) {
userParts.push(
"",
"# Resources the referee flagged in play this turn",
forecastLabels.join(", "),
"Treat this as a CHECKLIST, not a mandate: if the narration shows a REAL " +
"change to one of these — value actually moving to or from the world, by " +
"the field rules above — make sure it appears in `effects`. A resource " +
"that merely appears, or that the player produces or performs rather than " +
"receiving from the world, is NOT a change — record nothing; never invent " +
"one.",
);
}
// Pending bargains (Phase J): a price an NPC named in exchange for complying.
// A checklist so a deal struck in the prose lands on the ledger; omitted when
// empty so non-negotiation turns stay byte-identical.
const bargainLines = bargains.map((b) => b.trim()).filter(Boolean);
if (bargainLines.length > 0) {
userParts.push(
"",
"# Bargains a character set this turn",
...bargainLines.map((b) => `- ${b}`),
"Treat this as a CHECKLIST, not a mandate: ONLY if the narration shows the " +
"player ACCEPTING and actually paying a price that maps to a tracked " +
"resource above, record that cost in `effects`. If no deal was struck, or " +
"the price is not a tracked resource, change nothing — never invent a cost.",
);
}
// What the engine ALREADY charged/credited this turn — the accountant must not
// record it again from the prose that depicts it (the double-charge guard).
const appliedLines = applied
.filter((e) => e.after !== e.before)
.map((e) => {
const who = e.target === "player" ? "Player" : e.target;
const label = rules.resources[e.resource]?.label || e.resource;
const delta = e.after - e.before;
return `- ${who} ${label} ${delta > 0 ? `+${delta}` : delta}`;
});
if (appliedLines.length > 0) {
userParts.push(
"",
"# Already applied this turn (do NOT record these again)",
...appliedLines,
"The engine has ALREADY moved these and they are reflected in # Status above. " +
"The narration depicts this same change as it happens (the price being paid, " +
"the reward handed over) — recording it again would DOUBLE-COUNT it. Record " +
"ONLY further changes the narration adds beyond these; if the only change the " +
"scene shows is one already listed here, `effects` is [].",
);
}
userParts.push(
"",
"# Resulting narration (analyse this scene only)",
narration.trim(),
);
const user = userParts.join("\n");
return { system, user };
}
/** The mutable engine state Pass 2 reads and rewrites for one turn. */
export interface ExtractionContext {
sheet: Sheet;
combatants: Record<string, Sheet>;
/** Card-derived NPC sheets reused when a spawn matches a named character. */
npcSheets: Record<string, Sheet>;
/** Effects already applied this turn (e.g. the resolved pick), appended to. */
turnEffects: ResolvedEffect[];
gameOver: boolean;
/** Roster cap passed to `tidyRoster`. */
maxCombatants: number;
}
/** The post-application engine state Pass 2 hands back to the handler. */
export interface ExtractionResult {
sheet: Sheet;
combatants: Record<string, Sheet>;
npcSheets: Record<string, Sheet>;
turnEffects: ResolvedEffect[];
gameOver: boolean;
/** Next turn's tagged options, resolved by `resolveMove` when the player picks. */
pendingChoices: PendingChoice[];
}
/**
* Map a parsed option tag to a {@link PendingChoice} (the next-turn machine
* spec). `plain` carries no move; `risky` becomes a rolled move keyed on a stat +
* difficulty (the resource fallout is narrated next turn and captured by THAT
* turn's Pass 2 — the deltas stay empty); `cost` becomes a statless certain
* transaction whose single `hit` delta is gated by `resolveMove`'s affordability
* check at pick time. An option is exactly one kind in v1.
*/
function mapOption(o: Record<string, unknown>): PendingChoice {
const index = Math.round(Number(o.index));
const kind = o.kind;
const stat = typeof o.stat === "string" ? o.stat : "";
const difficulty = typeof o.difficulty === "string" ? o.difficulty : "";
const opposedBy = typeof o.opposedBy === "string" ? o.opposedBy : "";
const opposingStat = typeof o.opposingStat === "string" ? o.opposingStat : "";
const resource = typeof o.resource === "string" ? o.resource : "";
const amount = Number.isFinite(Number(o.amount)) ? Math.round(Number(o.amount)) : 0;
if (kind === "risky") {
// Opposition carries to next turn's pending move; the engine validates the
// foe id against the live roster at pick time (a foe that died meanwhile →
// no opposition), exactly like a stale `Delta.target`.
const move: Move = { stat, difficulty, opposedBy, opposingStat, miss: [], partial: [], hit: [] };
return { index, move };
}
if (kind === "cost" && resource) {
const move: Move = {
stat: "",
difficulty: "",
opposedBy: "",
opposingStat: "",
miss: [],
partial: [],
hit: [{ target: "player", resource, expr: signedExpr(amount) }],
};
return { index, move };
}
return { index, move: null }; // plain (and any unrecognised kind)
}
/**
* Apply a parsed accountant object onto the engine (pure). Spawns are applied
* first (so a same-scene hit on a fresh foe can land), then emergent effects via
* {@link applyEffects} — which clamps to bounds and gates an unaffordable spend
* per-delta (it never wholesale-rejects the turn; the prose already happened and
* the next `# Status` re-anchors). Defensive against a malformed object: unknown
* fields and junk entries are skipped, never thrown on.
*/
export function applyExtraction(
parsed: unknown,
ctx: ExtractionContext,
rules: RulesDefinition,
rng: Rng,
): ExtractionResult {
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
let sheet = ctx.sheet;
let combatants = ctx.combatants;
let gameOver = ctx.gameOver;
const turnEffects = [...ctx.turnEffects];
// 1. Spawns — bring new foes on stage before effects can target them.
const rawSpawns = Array.isArray(obj.spawns) ? obj.spawns : [];
const requests: SpawnRequest[] = rawSpawns
.filter((s): s is Record<string, unknown> => Boolean(s) && typeof s === "object")
.map((s) => ({
id: typeof s.id === "string" ? s.id : "",
archetype: typeof s.archetype === "string" ? s.archetype : "_default",
label: typeof s.label === "string" ? s.label : undefined,
}))
.filter((r) => r.id.trim() !== "");
if (requests.length > 0) {
// Pass the rng so dice-expression archetypes (`"1d3+1"`) roll varied stats.
combatants = applySpawns(combatants, requests, rules, ctx.npcSheets, rng);
combatants = tidyRoster(combatants, rules, ctx.maxCombatants);
}
// 2. Emergent effects — flat signed deltas, clamped/gated by the engine.
const rawEffects = Array.isArray(obj.effects) ? obj.effects : [];
const deltas: Delta[] = rawEffects
.filter((e): e is Record<string, unknown> => Boolean(e) && typeof e === "object")
.filter((e) => typeof e.resource === "string" && Number.isFinite(Number(e.amount)))
.map((e) => ({
target: typeof e.target === "string" && e.target.trim() ? e.target : "player",
resource: e.resource as string,
expr: signedExpr(Math.round(Number(e.amount))),
}));
if (deltas.length > 0) {
const applied = applyEffects({ player: sheet, combatants }, deltas, rng, rules);
sheet = applied.actors.player;
combatants = applied.actors.combatants;
if (applied.gameOver) gameOver = true;
turnEffects.push(...applied.effects);
}
// 2b. Departed — foes the scene removed WITHOUT a kill (fled, surrendered,
// talked down, or left behind by a scene/time change). Dropped from the
// live roster so they stop appearing in `# Status` and the status line.
// Without this a combatant only ever left at 0 HP, so a fight that ended
// any other way kept its foes pinned in the prompt forever. Applied AFTER
// effects so a parting blow this turn still lands. Unknown ids are no-ops.
const rawDeparted = Array.isArray(obj.departed) ? obj.departed : [];
const gone = new Set(
rawDeparted.filter((id): id is string => typeof id === "string" && id.trim() !== ""),
);
if (gone.size > 0) {
const remaining: Record<string, Sheet> = {};
for (const [id, s] of Object.entries(combatants)) {
if (!gone.has(id)) remaining[id] = s;
}
combatants = remaining;
}
// 3. Options — tag every numbered option the narration offered for next turn.
// Replace (not append): these are THIS reply's options in full.
const rawOptions = Array.isArray(obj.options) ? obj.options : [];
const pendingChoices: PendingChoice[] = rawOptions
.filter((o): o is Record<string, unknown> => Boolean(o) && typeof o === "object")
.filter((o) => Number.isFinite(Number(o.index)))
.map(mapOption);
return { sheet, combatants, npcSheets: ctx.npcSheets, turnEffects, gameOver, pendingChoices };
}