/**
* Free-form adjudication — "the referee" (Phase G3).
*
* Phase G2 split the creative pass (narrator) from the bookkeeping pass
* (accountant). G3 adds a THIRD role that runs BEFORE narration: the referee
* rules on *what the player's character is allowed to attempt* in this world, so
* a typed free-form action gets the same mechanical vetting a numbered pick
* already gets. A numbered pick was authored by the narrator and risk-tagged by
* the accountant; free text passed through nothing — that asymmetry is where the
* "I conjure a coin from nothing → it works" exploit lived.
*
* One bounded verdict, fixed-shape schema (the G2 lesson: constrained grammars
* are far more reliable on fixed shapes than on optional/union fields). A
* `verdict` discriminator says which fields matter:
* - "allowed" — ordinary, within the character's capabilities → no interference.
* This is the DEFAULT and the bias.
* - "roll" — plausible but uncertain → { stat, difficulty } feed the existing
* `resolveMove` engine (wired in Lot 2).
* - "resisted" — exceeds the character / breaks the world's rules → no roll; the
* narrator narrates the FAILURE in the world's voice.
* - "claim" — the player asserts a fact rather than acting → the narrator
* retains authority and treats it as a claim, not established fact.
*
* Pure and unit-testable, exactly like the rest of `rules/`: this file builds the
* schema, the prompt, and the pure application of a parsed verdict. The one
* impure boundary — the `respond({ structured })` call (and the dice for a
* `roll`) — lives in the handler, as with `sheetgen.ts` and the accountant. No
* `@lmstudio/sdk` import.
*
* See `docs/phase-g3-adjudication.md` for the decision record.
*/
import { z } from "zod";
import { Move } from "../state/schema.js";
import { DIFFICULTY_VALUES, keyEnum } from "./extract.js";
import { Peril, PERIL_VALUES, perilDeltas } from "./resolve.js";
import { RulesDefinition } from "./schema.js";
/** The four bounded verdicts the referee may return. */
export const VERDICT_VALUES = ["allowed", "roll", "resisted", "claim"] as const;
/**
* Build the Zod schema the referee's reply is forced into. Fixed shape with a
* `verdict` discriminator (more grammar-reliable than unions): `stat` and
* `difficulty` only matter for "roll", `reason` carries the in-world ruling for
* "resisted"/"claim", but every field is always present. `stat` is an enum over
* the universe's declared stats (so the referee can never name one the rules
* don't have), reusing the same `keyEnum` helper as the accountant/sheet schema.
*/
export function buildAdjudicationSchema(rules: RulesDefinition): z.ZodTypeAny {
const statKeys = Object.keys(rules.stats);
const resourceKeys = Object.keys(rules.resources);
return z.object({
verdict: z.enum(VERDICT_VALUES),
stat: keyEnum(statKeys), // meaningful for "roll"
difficulty: z.enum(DIFFICULTY_VALUES), // meaningful for "roll"
// Opposition (Phase G — opposition rolls), meaningful only for "roll" against
// a creature. `opposedBy` is the LIVE enemy id (a free string, validated by
// the engine like `Delta.target`; "" = unopposed, vs the environment);
// `opposingStat` is which of that foe's stats resists. Always present (fixed
// shape); ignored unless the verdict is "roll" and `opposedBy` names a live foe.
opposedBy: z.string(),
opposingStat: keyEnum(statKeys),
// The bodily danger to the PLAYER if this action goes wrong (Phase G3 —
// peril). Orthogonal to `difficulty` (which shapes the odds): `peril` shapes
// what a FAILURE does to the player's body, so a long-shot-and-deadly act is
// a roll with a stiff difficulty AND a high peril. Meaningful for "roll";
// always present (fixed shape). Defaults to "none" — almost everything.
peril: z.enum(PERIL_VALUES),
// The tracked resources this action plausibly puts in play (the consequence
// forecast — Phase G3 step 2). An enum over declared resources, so the
// referee can never name one the rules don't have; empty for an ordinary
// action. It flags WHICH dials move, never by how much.
affects: z.array(keyEnum(resourceKeys)),
reason: z.string(), // ≤1–2 sentences: the in-world ruling to narrate / log
});
}
/** The variable, per-turn inputs the referee reasons over (the prompt's tail). */
export interface AdjudicationContext {
/** World setting + narration style — what is possible in this fiction. */
worldTone: string;
/**
* The world's WRITTEN lore — the canon facts the narrator also reads
* (`# World lore`). A source of truth for the `claim` check: a fact present
* here is already established and must NOT be flagged. Optional; omitted from
* the prompt when empty (Phase G3.2).
*/
worldLore?: string;
/**
* The player's card prose AND current sheet. The capability anchor AND part of
* the established state: the sheet's resource amounts are the truth for the
* `claim`/`resisted` quantity check (a player cannot produce more than they
* hold — Phase G3.2 follow-up, the established-state boundary).
*/
playerCharacter: string;
/**
* The rolling summary of the story so far — durable context beyond the
* immediate scene, so an ambiguous action is judged against what has already
* been established (e.g. a fact the player asserts that was settled turns ago).
* Optional; omitted from the prompt when empty.
*/
storySummary?: string;
/**
* Earlier GM narration recalled from beyond the immediate scene — the verbatim
* past the narrator itself wrote, kept in the per-save recall store. Another
* source of truth for the `claim` check. CRUCIALLY this is narration ONLY: the
* player's own past lines are never included, because the player can never be a
* source of canon. Optional; omitted from the prompt when empty (Phase G3.2).
*/
pastEvents?: string;
/** The recent narration — what is actually true in the scene right now. */
currentScene: string;
/** The player's free-form declared action this turn. */
declaredAction: string;
}
/** A no-roll ruling injected as the `# Adjudication` block for the narrator. */
export interface Adjudication {
/** "resisted" = beyond the character; "claim" = an asserted fact, not an act. */
kind: "resisted" | "claim";
/** The in-world justification the narrator turns into prose. */
reason: string;
}
/**
* The dispatch result `applyAdjudication` hands back. Exactly one of the two is
* ever set (or neither, for "allowed"): `move` is rolled by the handler through
* `resolveMove` (the dice are the handler's impure boundary), `adjudication`
* becomes the `# Adjudication` block. They are mutually exclusive per turn.
*/
export interface AdjudicationResult {
/** A `roll` verdict's empty-delta move; null otherwise (Lot 2 fills this). */
move: Move | null;
/** A `resisted`/`claim` ruling; null otherwise. */
adjudication: Adjudication | null;
/**
* The declared resources the action puts in play (the consequence forecast).
* Forwarded to the narrator as the `# Consequences` block so it reflects the
* change as a concrete event; the accountant then quantifies it. Empty for an
* ordinary action, and cleared for `resisted`/`claim` (the action does not
* happen, so nothing moves).
*/
affects: string[];
/**
* The referee's one-line justification, whatever the verdict. Surfaced to the
* narrator on a `roll` as the attempt's stakes (so a miss/partial reads
* coherently); for `resisted`/`claim` it is also carried in `adjudication`.
*/
reason: string;
/**
* The bodily danger this free-form action put on the player (Phase G3 —
* peril): "none" unless it was a perilous `roll`. When non-"none" the `move`
* already carries the scaled vital deltas on its miss/partial tiers, so a
* failed roll wounds (and a `mortal` one can be fatal). The handler also reads
* this to keep a perilous physical contest standing against a hostile NPC's
* will (a danger to the body is not the NPC's to refuse away).
*/
peril: Peril;
}
/**
* Build the referee's prompt: a CONSTANT, cache-friendly system prefix (role +
* the four verdicts + the `allowed` bias + the `affects` rule, byte-identical
* every turn for a given universe), and a variable user tail — the world tone,
* the player character, the current scene, and the declared action, each under
* its own markdown header. The tail sits AFTER the prefix so the prefix's KV
* cache survives turns (same rule as the accountant). Genre-relative: the
* referee reads the world's own tone, never a hardcoded "no magic" rule.
*
* Deliberately carries NO few-shot examples for now — only the general rule — so
* we can test whether the principle alone is sufficient before adding any (which
* would risk pre-baking the very cases under test). Add examples back if live
* play shows the abstract rule isn't enough.
*/
export function buildAdjudicationPrompt(
rules: RulesDefinition,
ctx: AdjudicationContext,
): { system: string; user: string } {
const statKeys = Object.keys(rules.stats);
const statList = statKeys
.map((k) => `"${k}"${rules.stats[k].label ? ` (${rules.stats[k].label})` : ""}`)
.join(", ");
const resourceList = Object.keys(rules.resources)
.map((k) => `"${k}"${rules.resources[k].label ? ` (${rules.resources[k].label})` : ""}`)
.join(", ");
const lines: string[] = [
"You are an impartial referee for a collaborative role-play. You judge ONLY " +
"whether the player's declared action is something their character can " +
"plausibly attempt in this world. You never narrate, never decide the " +
"outcome's prose, and never touch numbers. Output only the structured verdict.",
"",
"You are given the world's tone, the player character (their description and " +
"current sheet), the recent scene, and the action the player just declared. " +
"Return exactly one verdict:",
"",
"- \"allowed\": an ordinary action well within what this character can do — " +
"anything an ordinary person could plainly do here. Ordinary features the " +
"place obviously has need no establishing — interacting with those is fine. " +
"THIS IS THE DEFAULT. When in doubt between \"allowed\" and \"roll\", choose " +
"\"allowed\" — but if the action needs a SPECIFIC place or thing the sources " +
"never established, that is a \"claim\", not \"allowed\".",
"- \"roll\": choose this ONLY when two things hold at once — the outcome is " +
"genuinely uncertain, AND failing would actually matter (a setback that " +
"changes the story or carries a real consequence). If there is nothing " +
"interesting at stake in failing — the action is routine, or its failure " +
"would be pointless or merely tedious — it is \"allowed\", not a roll. " +
"Reserve the dice for moments whose uncertainty is worth living through. " +
"When you do roll, set `stat` (which stat is tested) and `difficulty` " +
"(trivial | risky | dangerous | desperate); the dice, not you, decide.",
" When the roll is CONTESTED by a specific creature present in the scene — " +
"the player strikes, grapples, deceives, or outpaces a named foe listed " +
"under # Status — set `opposedBy` to that enemy's id and `opposingStat` to " +
"the stat that best fits HOW THAT FOE resists, drawn from the foe's own " +
"nature, not the player's: a brute overpowers with might, a nimble " +
"cutpurse evades with grace, a cunning one outwits with wits. Match the " +
"foe's defence to the action (might against a shove, grace against a grab, " +
"wits against a lie). The foe's own strength then shapes the odds, so a " +
"tough enemy is harder than a weak one. Leave `opposedBy` empty when nothing " +
"living opposes the action (climbing a wall, picking a lock) — that is the " +
"environment, handled by `difficulty` alone. When you DO oppose a foe, " +
"reserve `difficulty` for CIRCUMSTANCE only (terrain, surprise, being " +
"outnumbered) and default it to `risky` when the contest is purely between " +
"the two of you, so the foe's strength is not counted twice.",
"- \"resisted\": the action exceeds what this character can do, or breaks the " +
"world's established rules — a power they have never shown, magic in a world " +
"that has none, conjuring something from nothing. No roll: it cannot be " +
"attempted. Put the in-world reason in `reason`. NOTE: a merely RECKLESS or " +
"near-hopeless act that is still physically possible — charging armed men " +
"barehanded, a deadly leap, baiting a killer — is NOT \"resisted\". A doomed " +
"attempt is still an attempt: make it a \"roll\" with a stiff `difficulty` " +
"and a high `peril` (below), and let the dice — with the danger attached — " +
"decide. Reserve \"resisted\" for what the character literally cannot do.",
"- \"claim\": the player treats an UNESTABLISHED fact as already true — an " +
"object, a place, a person, a shared past, knowledge, or an event the world " +
"has not established. This holds EVEN WHEN the fact is never stated outright " +
"but is PRESUPPOSED by an action that could only proceed if it were already " +
"true. It holds EVEN WHEN the assertion is wrapped as pressure, insistence, " +
"persuasion, an action, or a question. Name the presupposed fact in " +
"`reason`; you keep authority over the world, and the narrator will let any " +
"character react in character but will NOT grant the fact on the player's " +
"say-so.",
"",
"Bias hard toward \"allowed\". Most actions are ordinary and pass untouched. " +
"Reserve \"roll\" for uncertain actions whose failure would genuinely " +
"matter, and \"resisted\" for real capability or world-rule violations — " +
"never to police flavour or ordinary competence, and never roll something " +
"routine just because it could technically be attempted. A \"miss\" on a " +
"roll means \"you tried and failed\"; \"resisted\" means \"you cannot even " +
"attempt this\" — they are different.",
"",
"Priority — check this FIRST, before any other verdict. An action does NOT " +
"become \"allowed\" just because the bodily motion is ordinary. If the " +
"action could only proceed when some UNESTABLISHED place, object, or person " +
"is real, then the action is INVENTING that thing: it is a \"claim\", not " +
"\"allowed\" and not a \"roll\". Neither a roll nor an \"allowed\" may " +
"launder an invented fact into the world for the player. Stronger still when " +
"the sources CONTRADICT the presupposed fact: that is always a \"claim\" — " +
"never let the player move into or use what the world says is not there. " +
"(The narrator still plays the beat; it simply will not confirm the fact.)",
"",
"WHAT COUNTS AS ESTABLISHED — your sole basis for a \"claim\". The player " +
"acts only within the established state and brings nothing new into being " +
"by declaring it. A fact is established ONLY if it appears in the material " +
"below: the world tone, the world lore, the story so far, the relevant " +
"past events, the current scene, or the player's character sheet. The " +
"player's declared action is NEVER a source of truth — the player cannot " +
"make something real by stating, implying, insisting on, or asking about " +
"it. Before any verdict, check whether the action takes as already-true a " +
"fact not in play: a place, a room, a person, an object, a shared past, " +
"something supposedly said, owned, or done. If you cannot find that fact " +
"in the sources below, it is a \"claim\". If it IS there, it is real — do " +
"not flag it. When unsure whether a presupposed fact was ever established, " +
"treat it as a \"claim\": the world, not the player, introduces new facts.",
"",
"The sheet is part of that established state: it bounds what the player " +
"actually HAS. An action requiring MORE of a resource than the sheet holds " +
"— producing, revealing, or spending a quantity the player does not possess " +
"— brings that surplus into being from nothing, exactly like an invented " +
"object. The amount on the sheet is the truth; a larger amount the player " +
"declares is not. Treat the surplus as unestablished: \"claim\" when the " +
"player merely asserts having it, \"resisted\" when they try to conjure it " +
"outright. (This is the same principle, not a special rule — the sheet is " +
"simply another source of truth.)",
"",
"`reason` is one or two short sentences in the world's own terms. For " +
"\"resisted\"/\"claim\" it is what the narrator turns into prose.",
"",
"Also set `peril`: how badly a FAILURE would hurt the player's body — judged " +
"from what the WORLD would do in answer, NOT from how the action is phrased. " +
"This is separate from `difficulty` (which only sets the odds): a long shot " +
"can be harmless and a near-sure thing can still be deadly if it goes wrong.",
" - \"none\": failure cannot physically harm the player. THE DEFAULT, and " +
"true of almost everything — talk, social friction, ordinary effort, most " +
"skill use. When unsure, it is \"none\".",
" - \"harmful\": failure means real injury — a brawl, a bad fall, a blade that " +
"could bite, rough handling by people who mean it.",
" - \"grave\": failure means a serious, maybe crippling wound — an armed fight " +
"against the odds, a long drop, cold deep water, being overpowered by force.",
" - \"mortal\": failure can plainly KILL — throwing an unarmed body at armed " +
"killers, a leap that breaks bodies, baiting a deadly foe with no way to win, " +
"an act the whole scene says is suicidal. A mortal failure can end the " +
"character's life outright, so reserve it for action that truly courts death.",
"Set `peril` only on a \"roll\" (it is the failure's cost); leave it \"none\" " +
"for \"allowed\"/\"resisted\"/\"claim\". Do not inflate it: ordinary danger " +
"is \"none\" or \"harmful\", and most turns are \"none\". But do not flinch " +
"from it either — when the player genuinely throws their life away, say so " +
"with \"mortal\", and the dice will hold them to it.",
"",
"Also set `affects`: the tracked resources this action plausibly puts in " +
"play — judged FROM THE SCENE AND ITS CONTEXT, never from the words alone, " +
"since the same act may change a resource in one situation and nothing in " +
"another. List a resource ONLY when value ACTUALLY MOVES between the " +
"character and the world. Flag a GAIN only when the world itself provides it " +
"(given, paid, found, won, taken from another); never flag a gain the " +
"player produces, reveals, or performs by their own action — the player is " +
"not a source of their own resources. A cost or loss may be flagged whenever " +
"the scene makes the player pay, spend, or lose it. If nothing actually " +
"passes to or from the world, do not flag it, however the scene describes " +
"the act. Most ordinary actions put nothing in play — then `affects` is " +
"empty (the common case). You never say HOW MUCH (no numbers): you only " +
"flag which dials the scene must move; the narration and the ledger set the " +
"amounts.",
];
if (statList) lines.push("", `Stats you may test (for \"roll\"): ${statList}.`);
if (resourceList) lines.push(`Resources you may flag in \"affects\": ${resourceList}.`);
const system = lines.join("\n");
const userParts = ["# World tone", ctx.worldTone.trim() || "(no setting given)"];
// The written lore canon — a source of truth for the `claim` check (a fact
// here is already established). Omitted when the universe ships no lore.
if ((ctx.worldLore ?? "").trim()) {
userParts.push("", "# World lore", ctx.worldLore!.trim());
}
userParts.push("", "# Player character", ctx.playerCharacter.trim() || "(no character card)");
// Durable context (the rolling summary), so an ambiguous action is judged
// against what is already established, not only the immediate scene. Omitted
// when there is no summary yet.
if ((ctx.storySummary ?? "").trim()) {
userParts.push("", "# Story so far", ctx.storySummary!.trim());
}
// Verbatim earlier GM narration recalled from beyond the window — a further
// source of truth (narration only; never the player's lines). Omitted when the
// store holds nothing yet.
if ((ctx.pastEvents ?? "").trim()) {
userParts.push("", "# Relevant past events", ctx.pastEvents!.trim());
}
userParts.push(
"",
"# Current scene",
ctx.currentScene.trim() || "(the scene is just beginning)",
"",
"# Declared action",
ctx.declaredAction.trim() || "(no explicit action)",
);
const user = userParts.join("\n");
return { system, user };
}
/**
* Apply a parsed referee verdict onto a small dispatch result (pure). "allowed"
* interferes with nothing. "resisted"/"claim" become the `# Adjudication` block.
* "roll" builds the SAME empty-delta `Move { stat, difficulty, [], [], [] }` the
* G2 `risky` option mapping produces — the tier is the dice output and the
* accountant captures the resource fallout from this turn's narration; the
* referee never writes a resource value (decision 6). The caller rolls it
* through `resolveMove` (the dice are the handler's impure boundary). A "roll"
* needs the dice engine, so without `rules` it collapses to "allowed".
*
* Defensive against a malformed object: an unknown/junk verdict is treated as
* "allowed", never thrown on (the prose still happens; the next turn re-anchors).
*/
export function applyAdjudication(
parsed: unknown,
_ctx: AdjudicationContext,
rules: RulesDefinition | null,
): AdjudicationResult {
const obj = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {};
const verdict = typeof obj.verdict === "string" ? obj.verdict : "allowed";
const reason = typeof obj.reason === "string" ? obj.reason.trim() : "";
// The consequence forecast: keep only declared resource keys, deduped. An
// unknown/junk entry is dropped, never thrown on.
const declared: Set<string> = rules ? new Set(Object.keys(rules.resources)) : new Set();
const affects = Array.isArray(obj.affects)
? [...new Set(obj.affects.filter((a): a is string => typeof a === "string" && declared.has(a)))]
: [];
if (verdict === "resisted") {
// The action does not happen → nothing moves; clear any forecast.
return { move: null, adjudication: { kind: "resisted", reason }, affects: [], reason, peril: "none" };
}
if (verdict === "claim") {
return { move: null, adjudication: { kind: "claim", reason }, affects: [], reason, peril: "none" };
}
if (verdict === "roll" && rules) {
const stat = typeof obj.stat === "string" ? obj.stat : "";
const difficulty = typeof obj.difficulty === "string" ? obj.difficulty : "";
// Opposition: a named live foe + the stat they resist with. The engine
// validates the id against current combatants (an unknown id → no
// opposition), so we forward the strings as-is.
const opposedBy = typeof obj.opposedBy === "string" ? obj.opposedBy : "";
const opposingStat = typeof obj.opposingStat === "string" ? obj.opposingStat : "";
// Peril (Phase G3): how badly a FAILURE hurts the player. When non-"none" the
// engine fills the miss/partial tiers with scaled vital damage, so a failed
// dangerous roll now wounds — and a `mortal` miss can be fatal — instead of a
// free-form roll costing nothing on the engine side (its old empty-delta
// shape). The `hit` tier stays empty (the player who succeeds takes no harm);
// the harm the action does to a FOE is still read from the prose by the
// accountant. An unknown/junk peril falls back to "none" (inert).
const peril: Peril = PERIL_VALUES.includes(obj.peril as Peril)
? (obj.peril as Peril)
: "none";
const { miss, partial } = perilDeltas(peril, rules);
const move: Move = { stat, difficulty, opposedBy, opposingStat, miss, partial, hit: [] };
return { move, adjudication: null, affects, reason, peril };
}
// "allowed", a "roll" with no engine, and any unrecognised verdict.
return { move: null, adjudication: null, affects, reason, peril: "none" };
}