/**
* Card-derived sheet generation (Phase G — structured generation slice).
*
* Instead of seeding every character with the rules' flat defaults, we let the
* model *propose* starting stats/resources that fit the character's prose, then
* the engine *disposes*: the proposal is forced into a schema built from the
* declared keys (the model can't invent a stat) and every value is clamped to
* the rules' bounds (the model can't cheat the range). This keeps the locked
* "numbers belong to the code" discipline (decision 1) — the model only nudges
* the *starting* values, once, inside guardrails the engine owns.
*
* Vital pools (resources flagged `endWhenZero`, e.g. HP) are deliberately NOT
* model-proposed: a character always starts at full health, however frail their
* description — the model decides *who they are* (stats, wealth), never *how
* wounded they begin*. Those pools are omitted from the schema/prompt and forced
* to their ceiling by `clampOverrides`.
*
* Pure and unit-testable: the schema builder, the prompt builder and the clamp
* are all here; the single LLM call (the impure boundary) lives in the handler,
* mirroring how dice are rolled there and the math is pure.
*/
import { z } from "zod";
import { RulesDefinition } from "./schema.js";
import { SheetOverrides } from "./init.js";
/** A character's prose, the only model-facing input to sheet generation. */
export interface SheetGenCard {
name: string;
description?: string;
personality?: string;
scenario?: string;
}
/**
* Build a Zod object schema whose only keys are the universe's declared stats
* and resources, each an integer. Passed to `respond({ structured })`, this is
* what makes the model physically unable to emit a key the rules don't declare.
*/
export function buildSheetSchema(rules: RulesDefinition): z.ZodTypeAny {
const statShape: Record<string, z.ZodTypeAny> = {};
for (const key of Object.keys(rules.stats)) statShape[key] = z.number().int();
const resShape: Record<string, z.ZodTypeAny> = {};
for (const [key, def] of Object.entries(rules.resources)) {
// Vital pools (e.g. HP) are never model-proposed — a character starts at
// full health regardless of who they are; only `clampOverrides` sets them.
if (def.endWhenZero) continue;
resShape[key] = z.number().int();
}
return z.object({
stats: z.object(statShape),
resources: z.object(resShape),
});
}
/**
* Build the system + user prompt for the generation call. The system message
* spells out each declared stat/resource with its label and allowed range, so
* the model assigns sensible in-range values; the user message carries the
* character's prose. Output-JSON-only is enforced structurally by the schema,
* but we say it too for models that ignore the grammar hint.
*/
export function buildSheetGenPrompt(
rules: RulesDefinition,
card: SheetGenCard,
): { system: string; user: string } {
const statLines = Object.entries(rules.stats).map(
([key, def]) =>
`- ${key}${def.label ? ` (${def.label})` : ""}: integer ${def.min}..${def.max}`,
);
const resLines = Object.entries(rules.resources)
// Vital pools (HP) start full and aren't the model's to set — omit them.
.filter(([, def]) => !def.endWhenZero)
.map(
([key, def]) =>
`- ${key}${def.label ? ` (${def.label})` : ""}: integer ${def.min}..${def.max ?? "∞"}`,
);
const system = [
"You assign starting tabletop-RPG numbers for a character from their description.",
"Pick values that fit the character: a strong brute scores high on a strength-like stat, a frail scholar low, and so on. Stay within every range; use the middle of a range for an average trait.",
"",
"Stats (modifiers):",
...statLines,
"",
"Resources (pools):",
...resLines,
"",
"Respond with JSON only, no prose.",
].join("\n");
const prose = [
`Name: ${card.name}`,
card.description?.trim() ? `Description: ${card.description.trim()}` : "",
card.personality?.trim() ? `Personality: ${card.personality.trim()}` : "",
card.scenario?.trim() ? `Scenario: ${card.scenario.trim()}` : "",
]
.filter(Boolean)
.join("\n");
return { system, user: `Character:\n${prose}` };
}
/** Normalize a character name into the key used for `state.npcSheets` and for
* matching a spawn back to its card (case-insensitive, trimmed). */
export function sheetKey(name: string): string {
return name.trim().toLowerCase();
}
function clamp(v: number, min: number, max: number): number {
return Math.max(min, Math.min(max, v));
}
/**
* Turn a raw (untrusted) model proposal into clamped {@link SheetOverrides}:
* keep only declared keys, coerce to a finite rounded integer, and clamp to the
* rules' bounds (resource `max: null` = unbounded above). Anything missing or
* unparsable is simply omitted, so `initSheet` fills it from the default. This
* is the guard that keeps a hallucinated value from ever reaching a live sheet.
*/
export function clampOverrides(
rules: RulesDefinition,
raw: unknown,
): SheetOverrides {
const obj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};
const rawStats =
obj.stats && typeof obj.stats === "object"
? (obj.stats as Record<string, unknown>)
: {};
const rawRes =
obj.resources && typeof obj.resources === "object"
? (obj.resources as Record<string, unknown>)
: {};
const stats: Record<string, number> = {};
for (const [key, def] of Object.entries(rules.stats)) {
const v = Number(rawStats[key]);
if (Number.isFinite(v)) stats[key] = clamp(Math.round(v), def.min, def.max);
}
const resources: Record<string, number> = {};
for (const [key, def] of Object.entries(rules.resources)) {
if (def.endWhenZero) {
// A vital pool always starts full: the model sets WHO a character is
// (stats), never how wounded they begin. Use the ceiling, or the default
// when the pool is unbounded above. Any proposed value is ignored.
resources[key] = def.max ?? def.default;
continue;
}
const v = Number(rawRes[key]);
if (Number.isFinite(v)) {
resources[key] = clamp(Math.round(v), def.min, def.max ?? Number.POSITIVE_INFINITY);
}
}
return { stats, resources };
}