/**
* Rendering of the mechanics into player-facing text + system-prompt blocks
* (Phase G). Pure string builders, consumed by the loop handler (status line,
* prepended to each reply) and the director's prompt assembler (`# Status`,
* `# Action resolution`, `# Conclusion`).
*
* The status line is the authoritative, code-rendered truth the player sees —
* never a model hallucination (locked decision 5).
*/
import { PendingChoice, Sheet } from "../state/schema.js";
import {
Actors,
DEFAULT_THRESHOLDS,
Resolution,
ResolvedEffect,
rollModifiers,
rollOdds,
Thresholds,
Tier,
} from "./resolve.js";
import { RulesDefinition } from "./schema.js";
/** Format one resource as "icon value/max" (max omitted when unbounded). */
function resourcePart(value: number, key: string, rules: RulesDefinition): string {
const def = rules.resources[key];
const tag = def?.icon || def?.label || key;
const max = def && def.max !== null && def.max !== undefined ? `/${def.max}` : "";
return `${tag} ${value}${max}`;
}
/** A combatant's vital (a number with no `/max` — the global max is the player's). */
function vitalPart(value: number, key: string, rules: RulesDefinition): string {
const def = rules.resources[key];
const tag = def?.icon || def?.label || key;
return `${tag} ${value}`;
}
/**
* The status shown after each reply: a one-line player segment (name then each
* resource as `icon value/max`), and — during a fight — the live combatants on a
* second line led by `⚔` (enemies show a bare value: their max is per-spawn, not
* the player's ceiling). When `effects` are given, this turn's NET change to a
* resource is folded in right after its value as a signed delta — `(-3)` /
* `(+5)` — so the player reads the value and what moved it in the same place; an
* unchanged resource shows no parentheses. Empty string when the rules declare
* no resources. This is the authoritative, code-rendered truth (decision 5);
* shown `includeInContext: false`, never fed back to the model.
*/
export function statusLine(
player: Sheet,
combatants: Record<string, Sheet>,
rules: RulesDefinition,
playerName = "",
effects: ResolvedEffect[] = [],
): string {
const keys = Object.keys(rules.resources);
if (keys.length === 0) return "";
// Net change this turn per `${target}|${resource}` (sum of the applied deltas),
// so the movement is shown inline next to the value it changed.
const net = new Map<string, number>();
for (const e of effects) {
const k = `${e.target}|${e.resource}`;
net.set(k, (net.get(k) ?? 0) + (e.after - e.before));
}
const delta = (target: string, resource: string): string => {
const d = net.get(`${target}|${resource}`) ?? 0;
return d === 0 ? "" : d > 0 ? ` (+${d})` : ` (${d})`;
};
const playerPart = keys
.map(
(k) =>
resourcePart(player.resources[k] ?? rules.resources[k].default, k, rules) +
delta("player", k),
)
.join(" · ");
const name = (playerName || player.label || "You").trim();
const playerSegment = `${name} ${playerPart}`;
const vitalKeys = keys.filter((k) => rules.resources[k].endWhenZero);
const foes = Object.entries(combatants).map(([id, c]) => {
const vitals = vitalKeys
.map(
(k) =>
vitalPart(c.resources[k] ?? rules.resources[k].default, k, rules) + delta(id, k),
)
.join(" ");
return [c.label, vitals].filter(Boolean).join(" ");
});
return foes.length > 0 ? `${playerSegment}\n⚔ ${foes.join(" · ")}` : playerSegment;
}
/**
* The player-facing "system" layer is QUALITATIVE, not numeric (a deliberate
* design choice): exact odds read like a debug table and kill the surprise, so
* instead we map the real, code-computed odds onto an evocative risk gauge before
* the roll and a luck-flavoured reveal after it. Numbers stay under the hood; the
* player feels the gamble and the swing of fate, not a spreadsheet. This lexicon
* is the (localized) flavour vocabulary — the only player-facing strings here, so
* they follow the play language. One lexicon per language offered in the
* "Response language" dropdown (see `director/language.ts`); anything unmapped
* (incl. "model-default") falls back to EN.
*/
interface ReadoutLexicon {
/** Header above the per-option risk gauge. */
previewHeader: string;
/** Five risk words, near-certain → desperate, matched to RISK_DOT. */
risk: [string, string, string, string, string];
/** Outcome flourishes, by how the dice fell. */
hitStrong: string;
hit: string;
partial: string;
miss: string;
missHeavy: string;
/** A foe's resistance, woven into a failed roll. */
holdFirm: (foe: string) => string;
}
const FR_READOUT: ReadoutLexicon = {
previewHeader: "Si tu tentes ta chance :",
risk: ["quasi sûr", "favorable", "incertain", "risqué", "désespéré"],
hitStrong: "Coup de maître — un succès éclatant !",
hit: "Les dés te sourient — réussite.",
partial: "Réussite arrachée — mais à un prix.",
miss: "Le sort te trahit — échec.",
missHeavy: "Catastrophe — un échec cuisant.",
holdFirm: (foe) => `${foe} tient bon`,
};
const EN_READOUT: ReadoutLexicon = {
previewHeader: "If you chance it:",
risk: ["near-certain", "favorable", "uncertain", "risky", "desperate"],
hitStrong: "Masterstroke — a resounding success!",
hit: "The dice favour you — success.",
partial: "You pull it off — but it costs you.",
miss: "Fate turns against you — failure.",
missHeavy: "Disaster — a crushing failure.",
holdFirm: (foe) => `${foe} holds firm`,
};
const ZH_READOUT: ReadoutLexicon = {
previewHeader: "若放手一搏:",
risk: ["几乎稳操胜券", "有利", "未知", "冒险", "九死一生"],
hitStrong: "神来之笔——大获全胜!",
hit: "骰子眷顾你——成功。",
partial: "你成功了——但代价不小。",
miss: "命运与你为敌——失败。",
missHeavy: "灾难——惨败收场。",
holdFirm: (foe) => `${foe}岿然不动`,
};
const ES_READOUT: ReadoutLexicon = {
previewHeader: "Si te arriesgas:",
risk: ["casi seguro", "favorable", "incierto", "arriesgado", "desesperado"],
hitStrong: "¡Obra maestra: un éxito rotundo!",
hit: "Los dados te sonríen: éxito.",
partial: "Lo consigues, pero te cuesta caro.",
miss: "El destino se vuelve en tu contra: fracaso.",
missHeavy: "Desastre: un fracaso aplastante.",
holdFirm: (foe) => `${foe} se mantiene firme`,
};
const RU_READOUT: ReadoutLexicon = {
previewHeader: "Если рискнёшь:",
risk: ["почти наверняка", "благоприятно", "неясно", "рискованно", "безнадёжно"],
hitStrong: "Мастерский ход — блистательный успех!",
hit: "Кости благосклонны к тебе — успех.",
partial: "Тебе удаётся — но не без потерь.",
miss: "Судьба отворачивается от тебя — провал.",
missHeavy: "Катастрофа — сокрушительный провал.",
holdFirm: (foe) => `${foe} держится стойко`,
};
const JA_READOUT: ReadoutLexicon = {
previewHeader: "賭けに出るなら:",
risk: ["ほぼ確実", "有利", "不確か", "危険", "絶望的"],
hitStrong: "会心の一撃——見事な大成功!",
hit: "賽はあなたに味方した——成功。",
partial: "成し遂げた——だが代償を払う。",
miss: "運命はあなたに背を向けた——失敗。",
missHeavy: "大惨事——手痛い失敗。",
holdFirm: (foe) => `${foe}は持ちこたえる`,
};
const DE_READOUT: ReadoutLexicon = {
previewHeader: "Wenn du es wagst:",
risk: ["fast sicher", "günstig", "ungewiss", "riskant", "aussichtslos"],
hitStrong: "Meisterstück — ein voller Erfolg!",
hit: "Die Würfel sind dir gewogen — Erfolg.",
partial: "Du schaffst es — doch es kostet dich.",
miss: "Das Schicksal wendet sich gegen dich — Fehlschlag.",
missHeavy: "Katastrophe — ein vernichtender Fehlschlag.",
holdFirm: (foe) => `${foe} hält stand`,
};
const PT_READOUT: ReadoutLexicon = {
previewHeader: "Se você arriscar:",
risk: ["quase certo", "favorável", "incerto", "arriscado", "desesperado"],
hitStrong: "Golpe de mestre — um sucesso retumbante!",
hit: "Os dados sorriem para você — sucesso.",
partial: "Você consegue — mas tem um preço.",
miss: "O destino se volta contra você — fracasso.",
missHeavy: "Desastre — um fracasso esmagador.",
holdFirm: (foe) => `${foe} se mantém firme`,
};
const KO_READOUT: ReadoutLexicon = {
previewHeader: "운을 시험한다면:",
risk: ["거의 확실", "유리함", "불확실", "위험", "절망적"],
hitStrong: "회심의 일격 — 눈부신 대성공!",
hit: "주사위가 너에게 미소 짓는다 — 성공.",
partial: "해내지만 — 대가를 치른다.",
miss: "운명이 너에게 등을 돌린다 — 실패.",
missHeavy: "재앙 — 처참한 실패.",
holdFirm: (foe) => `${foe}이(가) 버텨낸다`,
};
const IT_READOUT: ReadoutLexicon = {
previewHeader: "Se tenti la sorte:",
risk: ["quasi certo", "favorevole", "incerto", "rischioso", "disperato"],
hitStrong: "Colpo da maestro — un successo clamoroso!",
hit: "I dadi ti sorridono — successo.",
partial: "Ce la fai — ma a caro prezzo.",
miss: "La sorte ti volta le spalle — fallimento.",
missHeavy: "Disastro — un fallimento bruciante.",
holdFirm: (foe) => `${foe} tiene duro`,
};
/** Readout lexicon per language code (primary subtag), keyed as in LANGUAGES. */
const READOUTS: Record<string, ReadoutLexicon> = {
en: EN_READOUT,
fr: FR_READOUT,
zh: ZH_READOUT,
es: ES_READOUT,
ru: RU_READOUT,
ja: JA_READOUT,
de: DE_READOUT,
pt: PT_READOUT,
ko: KO_READOUT,
it: IT_READOUT,
};
/**
* Pick the readout lexicon for a response-language code. Matches on the primary
* subtag ("pt-BR" → "pt"); anything unmapped ("model-default", unknown) → EN.
*/
export function readoutLexicon(languageCode: string): ReadoutLexicon {
const primary = (languageCode || "").toLowerCase().split(/[-_]/)[0];
return READOUTS[primary] ?? EN_READOUT;
}
/** Risk-gauge dots, near-certain → desperate (a glanceable colour, no number). */
const RISK_DOT = ["🟢", "🟢", "🟡", "🟠", "🔴"];
/** Circled option numbers 0–9, falling back to "#n" beyond. */
const CIRCLED = ["⓪", "①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨"];
function circled(n: number): string {
return n >= 0 && n <= 9 ? CIRCLED[n] : `#${n}`;
}
/** Map a clean-failure probability onto a 0–4 risk tier (the gauge index). */
function riskTier(missProb: number): number {
if (missProb < 0.1) return 0;
if (missProb < 0.25) return 1;
if (missProb < 0.4) return 2;
if (missProb < 0.55) return 3;
return 4;
}
/**
* The "before" reading — the taste of the gamble. For each risky option the
* narration is about to offer, a glanceable risk gauge (colour dot + evocative
* word), derived from the real odds (computed in code) but shown WITHOUT numbers,
* so the player feels the stakes yet keeps the surprise. Plain / certain-cost
* options carry no roll and are omitted. Pure; null when nothing is rollable.
*/
export function riskPreview(
pendingChoices: PendingChoice[],
actors: Actors,
thresholds: Thresholds = DEFAULT_THRESHOLDS,
lex: ReadoutLexicon = EN_READOUT,
): string | null {
const rows: string[] = [];
for (const pc of pendingChoices) {
if (!pc.move || !pc.move.stat) continue; // plain / certain-cost → no dice
const m = rollModifiers(pc.move, actors);
const tier = riskTier(rollOdds(m.bonus, thresholds).miss);
rows.push(` ${circled(pc.index)} ${RISK_DOT[tier]} ${lex.risk[tier]}`);
}
return rows.length > 0 ? [`🎲 ${lex.previewHeader}`, ...rows].join("\n") : null;
}
/**
* The "after" reading — the swing of fate. A single luck-flavoured line for the
* roll that just resolved: how the dice fell (a masterstroke, a clean success, a
* costly partial, a betrayal, a disaster), with a beaten foe's resistance woven
* into a failure. Derived from the real tier + how far the total cleared/missed
* the thresholds, shown WITHOUT numbers. Null for a no-roll turn. Pure.
*/
export function luckReadout(
resolution: Resolution | null,
thresholds: Thresholds = DEFAULT_THRESHOLDS,
lex: ReadoutLexicon = EN_READOUT,
): string | null {
if (!resolution || resolution.noRoll) return null;
const r = resolution;
let phrase: string;
if (r.tier === "hit") {
phrase = r.total >= thresholds.full + 2 ? lex.hitStrong : lex.hit;
} else if (r.tier === "partial") {
phrase = lex.partial;
} else {
phrase = r.total <= thresholds.partial - 3 ? lex.missHeavy : lex.miss;
const foe = r.opposingLabel || r.opposedBy;
if (foe) phrase = `${phrase} ${lex.holdFirm(foe)}.`;
}
return `🎲 ${phrase}`;
}
/** The `# Status` system block: authoritative current values for the model. */
export function statusBlock(
player: Sheet,
combatants: Record<string, Sheet>,
rules: RulesDefinition,
): string | null {
const resKeys = Object.keys(rules.resources);
const statKeys = Object.keys(rules.stats);
if (resKeys.length === 0 && statKeys.length === 0) return null;
const res = resKeys
.map((k) => `${rules.resources[k].label || k} ${player.resources[k] ?? rules.resources[k].default}`)
.join(", ");
const stats = statKeys
.map((k) => `${rules.stats[k].label || k} ${player.stats[k] ?? rules.stats[k].default}`)
.join(", ");
const lines = ["# Status"];
lines.push(`Player — ${[res, stats].filter(Boolean).join("; ")}`);
const foes = Object.entries(combatants);
if (foes.length > 0) {
const vitalKeys = resKeys.filter((k) => rules.resources[k].endWhenZero);
const parts = foes.map(([id, c]) => {
const vitals = vitalKeys
.map((k) => `${rules.resources[k].label || k} ${c.resources[k] ?? rules.resources[k].default}`)
.join(", ");
return `${id} "${c.label}" (${vitals})`;
});
lines.push(`Enemies in scene — ${parts.join("; ")}`);
lines.push(
`Active enemy ids: ${foes.map(([id]) => id).join(", ")}. Refer to these foes ` +
"consistently and do not duplicate or rename them.",
);
lines.push(
"While a foe's health is above its minimum here, a blow cannot finish it: " +
"narrate it wounded, staggered, or reeling, but never killed, knocked out, " +
"or beaten down — the numbers, not a single strike, decide when a healthy " +
"foe FALLS in combat, so one hit rarely ends it. A fight can still END " +
"without a death, though: the player may break off or flee, a foe may rout, " +
"surrender, yield, or be talked down, or the scene may move to another " +
"place or time. When that happens the encounter is over and those foes are " +
"simply no longer present — stop staging them; do not drag a foe the scene " +
"has left behind back into it.",
);
}
lines.push(
"These numbers are authoritative. Narrate in agreement with them; never " +
"restate or change these values yourself. Do not, on your own, kill or take " +
"the player out of the scene while their health is above its minimum — the " +
"numbers decide that. But when the resolution this turn brings the player's " +
"health DOWN TO its minimum, they HAVE fallen: narrate it honestly and let it " +
"be final — do not soften a fatal blow into a near-miss or let them shrug it off.",
);
lines.push(
"Every amount is a whole number — never offer, name, accept, or narrate a " +
"fractional or half amount (no \"two and a half coins\", no \"2.5\"); any " +
"price, cost, or reward is a whole number.",
);
return lines.join("\n");
}
const TIER_LABEL: Record<Tier, string> = {
miss: "miss",
partial: "partial success",
hit: "full success",
};
/**
* The `# Action resolution` block describing this turn's roll + applied effects,
* so the model narrates the outcome already decided by the dice. Null when no
* roll happened (a plain narrative turn).
*/
export function resolutionBlock(
resolution: Resolution | null,
rules: RulesDefinition,
/**
* The referee's one-line framing of what was attempted and why it was
* uncertain (Phase G3), surfaced on a free-form `roll` so a miss/partial reads
* coherently. Empty for a numbered-pick roll (the narrator authored it) — then
* nothing extra is shown.
*/
note = "",
): string | null {
if (!resolution) return null;
const r = resolution;
const mod = r.modifier >= 0 ? `+${r.modifier}` : `${r.modifier}`;
// The difficulty bonus is shown in the breakdown (so the arithmetic adds up for
// the model) but never as odds — this block is read by the model, not the
// player, and the difficulty itself is telegraphed in prose (decisions 4 & 5).
const diff = r.difficultyModifier ?? 0;
const diffPart = diff !== 0 ? ` ${diff > 0 ? `+${diff}` : `${diff}`} (difficulty)` : "";
// Opposition: a live foe's stat lowered the total. Show it in the breakdown
// (so the arithmetic adds up for the model and it knows the foe resisted), as
// a labelled term — never as odds (this block is read by the model, not the
// player; the foe's strength is telegraphed in prose).
const opp = r.opposingModifier ?? 0;
const oppPart =
opp !== 0
? ` ${opp > 0 ? `+${opp}` : `${opp}`} (vs ${r.opposingLabel || r.opposedBy}` +
`${r.opposingStat ? `'s ${r.opposingStat}` : ""})`
: "";
// A statless move is a certain transaction (no dice) — say so plainly instead
// of printing a phantom 2d6 the player never rolled.
const rollLine = r.noRoll
? "Certain outcome (no dice roll — a guaranteed cost/reward)."
: `Roll: 2d6 (${r.d1}+${r.d2})${r.stat ? ` ${mod} ${r.stat}` : ""}${diffPart}${oppPart} = ${r.total} ` +
`→ ${TIER_LABEL[r.tier]}.`;
const lines = ["# Action resolution", rollLine];
if (note.trim()) lines.push(`What the player attempted (narrate this outcome): ${note.trim()}`);
// The tier is a HARD constraint on the outcome's shape, not a flavour hint: a
// weak local model otherwise narrates a clean win on any roll. A miss must
// cost; a partial must not be a flawless victory.
if (!r.noRoll) {
if (r.tier === "miss") {
lines.push(
"This is a MISS. The attempt does NOT succeed: narrate it failing or " +
"backfiring, with a real consequence the player must now deal with. Do " +
"not let it work out anyway.",
);
} else if (r.tier === "partial") {
lines.push(
"This is a PARTIAL success. It works, but ONLY at a real cost or with a " +
"complication — a price paid, ground given, a new danger, a hard trade, " +
"an incomplete result. Never narrate a clean, decisive, cost-free win on " +
"a partial.",
);
} else {
lines.push(
"This is a FULL success. The action achieves exactly what the player set " +
"out to do — narrate it working, not a setback. If it was aimed at a " +
"person (a request, a negotiation, persuasion, intimidation), that person " +
"GRANTS the result the player sought; do not have them refuse, stonewall, " +
"hold firm, or undo it afterwards. A character's reluctance may colour HOW " +
"they yield (grudgingly, with a remark), never WHETHER.",
);
}
}
for (const e of r.effects) {
const who = e.target === "player" ? "Player" : e.target;
const def = rules.resources[e.resource];
const icon = def?.icon ? `${def.icon} ` : "";
const label = def?.label || e.resource;
const change = e.after - e.before;
const signed = change > 0 ? `+${change}` : `${change}`;
lines.push(`- ${who} ${icon}${label}: ${e.before} → ${e.after} (${signed})`);
}
for (const e of r.rejected ?? []) {
const def = rules.resources[e.resource];
const icon = def?.icon ? `${def.icon} ` : "";
const label = def?.label || e.resource;
lines.push(
`- The player CANNOT afford ${icon}${label}: they have ${e.before}, the cost is ` +
`${-e.amount}. The action FAILS — nothing is spent or gained.`,
);
}
for (const id of r.defeated) lines.push(`- ${id} is defeated and leaves the scene.`);
if ((r.rejected ?? []).length > 0) {
lines.push(
"Narrate the player coming up short — the transaction does NOT happen; " +
"they keep what they had and gain nothing. Do not contradict these numbers.",
);
} else {
lines.push("Narrate this outcome. Do not contradict these numbers.");
}
return lines.join("\n");
}
/**
* The `# Adjudication` block: a no-roll ruling from the referee (Phase G3),
* telling the narrator to honour a `resisted`/`claim` verdict on the player's
* free-form action BEFORE it writes the scene. Null when the referee allowed the
* action (or rolled it — then the `# Action resolution` block carries the
* outcome instead; the two are mutually exclusive per turn). Pure; the verdict's
* shape mirrors the handler's `Adjudication` so render stays decoupled from the
* referee module.
*/
export function adjudicationBlock(
adjudication: { kind: "resisted" | "claim"; reason: string } | null,
): string | null {
if (!adjudication) return null;
const reason = adjudication.reason.trim();
const lines = [
"# Adjudication",
"An impartial referee has assessed the player's declared action.",
];
if (adjudication.kind === "resisted") {
lines.push(
"The attempt is beyond what this character can do in this world" +
(reason ? `: ${reason}` : ".") +
" Narrate the attempt FAILING, in the world's own voice — describe them " +
"trying and the world not yielding. Never grant the outcome they reached for.",
);
} else {
lines.push(
"The player's action takes as already-true a fact that is NOT established " +
"in this fiction" +
(reason ? `: ${reason}` : ".") +
" Treat that fact as false. It is real only if it already appears in the " +
"world, the lore, the story so far, the recalled past events, or earlier " +
"narration — NEVER because the player stated, implied, or insisted on it. " +
"Do NOT repeat it as true, confirm it, echo the player's wording for it, " +
"or build the scene on it. Have the world and its characters meet the " +
"MISTAKEN premise in character: correct or contradict it plainly where " +
"natural, or otherwise treat it as unknown. You keep full authority over " +
"the world; never grant the fact on the player's say-so.",
);
}
return lines.join("\n");
}
/**
* The `# Consequences` block: the referee's forecast (Phase G3) of which tracked
* resources the player's free-form action puts in play, so the narrator reflects
* the change as a concrete in-scene event (the price paid, the wound felt) rather
* than forgetting it. The referee names only WHICH dials move; the narration and
* the accountant set the amounts (the "code owns the numbers" discipline). Null
* when nothing is in play — the common case — so an ordinary turn is unchanged.
* Pure; only declared resource keys render.
*/
export function consequencesBlock(
affects: string[],
rules: RulesDefinition,
): string | null {
const keys = affects.filter((k) => rules.resources[k]);
if (keys.length === 0) return null;
const names = keys.map((k) => rules.resources[k].label || k).join(", ");
return [
"# Consequences",
"An impartial referee judged that the player's declared action puts these " +
`tracked values in play: ${names}. Make the world apply the consequence ` +
"within the scene as a concrete event — the price is paid, the toll is " +
"exacted, the wound is felt — unless the fiction clearly negates it (it is " +
"given freely, the blow misses). Narrate it as something that happens, never " +
"as a number; the engine tracks the figures.",
].join("\n");
}
/** The `# Conclusion` instruction shown once the player's run ends. */
export function conclusionBlock(): string {
return [
"# Conclusion",
"The player character has fallen. Bring the adventure to a close with a " +
"short, fitting ending. Do not offer further choices or continue the scene.",
].join("\n");
}