/**
* PbtA move resolution (Phase G). Pure: given the player's move, the live
* actors, the two d6 already shown, and an injected RNG for any stakes dice, it
* computes the outcome tier, applies the tier's resource deltas to the right
* target sheets (clamped to the rules' bounds), and flags defeats / game-over.
*
* The roller is always the player (moves are the player's actions); the stat
* modifier comes from the player's sheet. Determinism is fully parameterized
* (d1/d2 + rng are inputs), so it is exhaustively unit-testable and never calls
* `Math.random` itself.
*
* A move with no `stat` is a **certain transaction, not a gamble**: there is no
* roll, no tier lottery — its `hit` deltas (where the certain cost/reward lives)
* apply unconditionally. This is the deterministic path a flat price (buying a
* round, paying a toll) must take instead of being funnelled through 2d6.
*
* `applyEffects` exposes that same deterministic application without a move at
* all: a flat list of resource deltas, applied and clamped. The loop handler
* drives both entry points; the engine itself stays pure.
*/
import { Delta, Move, Sheet } from "../state/schema.js";
import { rollExpr, Rng } from "./dice.js";
import { ResourceDef, RulesDefinition } from "./schema.js";
export type Tier = "miss" | "partial" | "hit";
/** PbtA tier thresholds (defaults: partial 7+, full hit 10+). */
export interface Thresholds {
partial: number;
full: number;
}
export const DEFAULT_THRESHOLDS: Thresholds = { partial: 7, full: 10 };
/**
* Flat bonus a move's declared difficulty adds to the 2d6 total. The narrator
* judges how hard a risky option is (it sees the fiction); the engine owns the
* math (it owns the stable stats), so difficulty becomes a bounded modifier here
* rather than a model-emitted number (Phase G2, decision 4). An unknown/empty
* difficulty maps to 0, so a move without one rolls exactly as before. The
* resulting odds are never shown to the player (decision 5).
*/
export const DIFFICULTY_MODIFIERS: Record<string, number> = {
trivial: 2,
risky: 0,
dangerous: -2,
desperate: -4,
};
/**
* Bodily-danger tiers the referee may attach to a free-form action (Phase G3 —
* peril). `difficulty` shapes the ODDS of the roll; `peril` shapes what a FAILURE
* does to the player's body. They are orthogonal: a risky-but-safe action (no
* peril) and a long-shot-and-deadly one (desperate + mortal) are both rolls.
*/
export const PERIL_VALUES = ["none", "harmful", "grave", "mortal"] as const;
export type Peril = (typeof PERIL_VALUES)[number];
/** A single peril blow, as fractions of the vital pool's span (max − min). */
interface PerilBlow {
/** Guaranteed floor of damage (fraction of the span). */
flat: number;
/** Size of one variable d-die of extra damage (fraction of the span). */
die: number;
}
/**
* How hard a perilous free-form action hits the player's VITAL pool when it goes
* wrong, per outcome tier — the player's own body is what is at stake here (the
* harm the action does to a foe is read from the prose by the accountant). Per
* tier:
* - `miss` — the danger lands in full (the attempt failed outright).
* - `partial` — a lighter blow (it half-worked, but the body still paid).
* - `hit` — nothing (the player pulled it off cleanly; not represented here).
* Amounts are fractions of the vital span so the table travels across rule sets
* (a 10-HP port town and a 100-HP saga both feel right). `mortal`'s miss is built
* to REACH and overshoot the full span, so a genuinely suicidal act can drive a
* healthy player to the floor in a single resolution — a deserved, one-roll
* game-over — while a lucky low roll may leave them clinging by a thread.
*/
const PERIL_DAMAGE: Record<Peril, { miss: PerilBlow; partial: PerilBlow }> = {
none: { miss: { flat: 0, die: 0 }, partial: { flat: 0, die: 0 } },
harmful: { miss: { flat: 0, die: 0.3 }, partial: { flat: 0, die: 0.15 } },
grave: { miss: { flat: 0.2, die: 0.4 }, partial: { flat: 0, die: 0.25 } },
mortal: { miss: { flat: 0.8, die: 0.5 }, partial: { flat: 0.2, die: 0.4 } },
};
/**
* The player's vital pool — the `endWhenZero` resource (HP) — with its floor and
* span, used to scale peril damage. Null when the rules declare none (then peril
* has nothing to damage and is inert). The first such resource wins.
*/
export function vitalPool(
rules: RulesDefinition,
): { key: string; min: number; span: number } | null {
for (const [key, def] of Object.entries(rules.resources)) {
if (!def.endWhenZero) continue;
const min = def.min ?? 0;
const max = def.max ?? def.default ?? min + 10;
return { key, min, span: Math.max(1, max - min) };
}
return null;
}
/** Build a negative damage expression for one peril blow scaled to `span`, or
* null when it rounds to nothing (peril none, or a degenerate tiny pool). */
function blowExpr(blow: PerilBlow, span: number): string | null {
const flat = Math.max(0, Math.round(blow.flat * span));
const die = Math.round(blow.die * span);
if (flat <= 0 && die < 1) return null;
if (die < 1) return `-${flat}`; // flat-only damage
const dice = `-1d${Math.max(2, die)}`; // at least a d2 of variance
return flat > 0 ? `${dice}-${flat}` : dice; // e.g. "-1d5-8", "-1d3"
}
/**
* The vital deltas a perilous free-form `roll` carries on its `miss` / `partial`
* tiers. Empty for `none`, when the rules declare no vital pool, or when the blow
* scales to nothing. Pure — the amounts are dice expressions rolled later by
* {@link resolveMove}, so the wound varies turn to turn (and a `mortal` miss may
* or may not be fatal on any given roll). The `hit` tier never carries damage:
* the player who succeeds takes nothing.
*/
export function perilDeltas(
peril: Peril,
rules: RulesDefinition,
): { miss: Delta[]; partial: Delta[] } {
const empty = { miss: [] as Delta[], partial: [] as Delta[] };
const spec = PERIL_DAMAGE[peril];
if (!spec || peril === "none") return empty;
const pool = vitalPool(rules);
if (!pool) return empty;
const mk = (blow: PerilBlow): Delta[] => {
const expr = blowExpr(blow, pool.span);
return expr ? [{ target: "player", resource: pool.key, expr }] : [];
};
return { miss: mk(spec.miss), partial: mk(spec.partial) };
}
/** A single applied resource change, with the before/after for narration. */
export interface ResolvedEffect {
target: string;
resource: string;
amount: number;
before: number;
after: number;
}
/** The live actors a move resolves against. */
export interface Actors {
player: Sheet;
combatants: Record<string, Sheet>;
}
/** The flat bonuses a move adds to the 2d6, with the pieces broken out so the
* resolution block and the odds preview can show the same arithmetic. */
export interface RollModifiers {
stat: string;
/** True when the move has no stat: a certain transaction, not a gamble. */
noRoll: boolean;
modifier: number;
difficultyModifier: number;
opposingModifier: number;
opposedBy: string;
opposingStat: string;
opposingLabel: string;
/** Sum added to the raw 2d6 (modifier + difficulty + opposition). */
bonus: number;
}
/**
* Compute every modifier a move applies to the 2d6, WITHOUT rolling — the single
* source of truth for the roll's arithmetic, shared by `resolveMove` (which adds
* the dice) and the odds preview (which weighs it against the 2d6 distribution).
* Opposition is validated against the live roster exactly as `resolveMove` does:
* an unknown/dead `opposedBy` id (or a statless move) yields no opposition.
*/
export function rollModifiers(move: Move | null, actors: Actors): RollModifiers {
const stat = move?.stat ?? "";
const noRoll = move !== null && stat === "";
const modifier = stat ? (actors.player.stats[stat] ?? 0) : 0;
const difficultyModifier = DIFFICULTY_MODIFIERS[move?.difficulty ?? ""] ?? 0;
const opposed =
!noRoll && move?.opposedBy && move.opposingStat ? actors.combatants[move.opposedBy] : undefined;
const opposingStat = opposed ? move!.opposingStat : "";
const opposingLabel = opposed ? opposed.label : "";
const opposingModifier = opposed ? -(opposed.stats[opposingStat] ?? 0) : 0;
return {
stat,
noRoll,
modifier,
difficultyModifier,
opposingModifier,
opposedBy: opposed ? move!.opposedBy : "",
opposingStat,
opposingLabel,
bonus: modifier + difficultyModifier + opposingModifier,
};
}
/** The 2d6 sum distribution: weight (out of 36) for each sum, indexed 2..12. */
const TWO_D6_WEIGHTS = [0, 0, 1, 2, 3, 4, 5, 6, 5, 4, 3, 2, 1];
/** Probabilities of each tier for a `2d6 + bonus` roll against the thresholds —
* exact (summed over the 36 outcomes), pure, for the player-facing odds preview. */
export function rollOdds(
bonus: number,
thresholds: Thresholds = DEFAULT_THRESHOLDS,
): { miss: number; partial: number; hit: number } {
let miss = 0;
let partial = 0;
let hit = 0;
for (let sum = 2; sum <= 12; sum++) {
const total = sum + bonus;
const w = TWO_D6_WEIGHTS[sum];
if (total >= thresholds.full) hit += w;
else if (total >= thresholds.partial) partial += w;
else miss += w;
}
return { miss: miss / 36, partial: partial / 36, hit: hit / 36 };
}
/** Everything the assembler + handler need from a resolved move. */
export interface Resolution {
stat: string;
/** True when the move had no stat: a certain transaction, no dice rolled. */
noRoll: boolean;
d1: number;
d2: number;
modifier: number;
/** Flat bonus from the move's declared difficulty (trivial +2 … desperate −4). */
difficultyModifier: number;
/**
* A live foe's stat subtracted from the total (opposition rolls): negative when
* a foe resists, 0 when the roll is unopposed (vs the environment). The opposed
* combatant / stat / label echo the resolved opposition for the resolution block.
*/
opposingModifier: number;
opposedBy: string;
opposingStat: string;
opposingLabel: string;
total: number;
tier: Tier;
effects: ResolvedEffect[];
/**
* Deltas the engine refused because the player could not afford them — a
* certain cost that would push a spendable (non-`endWhenZero`) resource below
* its floor. `after` equals `before` (nothing changed); `amount` is what was
* attempted. The model is told to narrate the failure.
*/
rejected: ResolvedEffect[];
/** False when a certain transaction was rejected for lack of funds. */
affordable: boolean;
/** Combatant ids removed this resolution (their `endWhenZero` hit min). */
defeated: string[];
/** True when the player's `endWhenZero` resource reached its floor. */
gameOver: boolean;
/** Post-resolution actors (fresh copies; inputs are not mutated). */
actors: Actors;
}
function cloneSheet(s: Sheet): Sheet {
return {
initialized: s.initialized,
label: s.label,
stats: { ...s.stats },
resources: { ...s.resources },
};
}
function clampResource(value: number, def?: ResourceDef): number {
const min = def?.min ?? 0;
let v = Math.max(min, value);
if (def && def.max !== null && def.max !== undefined) v = Math.min(def.max, v);
return v;
}
function tierFor(total: number, t: Thresholds): Tier {
if (total >= t.full) return "hit";
if (total >= t.partial) return "partial";
return "miss";
}
/** A delta with its rolled amount and prospective before/after, pre-application. */
interface PreparedDelta {
delta: Delta;
sheet: Sheet | undefined;
def?: ResourceDef;
before: number;
amount: number;
after: number;
}
/** Roll each delta's amount and compute its clamped target value, WITHOUT
* applying it — so an affordability gate can decide before committing. */
function prepareDeltas(
player: Sheet,
combatants: Record<string, Sheet>,
deltas: Delta[],
rng: Rng,
rules: RulesDefinition,
): PreparedDelta[] {
return deltas.map((delta) => {
const sheet = delta.target === "player" ? player : combatants[delta.target];
const def = rules.resources[delta.resource];
const before = sheet ? sheet.resources[delta.resource] ?? def?.default ?? 0 : 0;
const amount = rollExpr(delta.expr, rng);
const after = clampResource(before + amount, def);
return { delta, sheet, def, before, amount, after };
});
}
/**
* Whether a prepared delta is an **unaffordable spend**: a negative change to a
* spendable (non-`endWhenZero`) resource that would breach its floor. Vital
* pools (`endWhenZero`, e.g. HP) are never "unaffordable" — taking lethal damage
* is an intended outcome, so they clamp/defeat as before. Gains never gate.
*/
function isUnaffordable(p: PreparedDelta): boolean {
if (!p.sheet || !p.def) return false;
if (p.def.endWhenZero) return false;
if (p.amount >= 0) return false;
const min = p.def.min ?? 0;
return p.before + p.amount < min;
}
/** Commit prepared deltas to their sheets (optionally skipping some); collect effects. */
function commit(
prepared: PreparedDelta[],
skip?: (p: PreparedDelta) => boolean,
): ResolvedEffect[] {
const effects: ResolvedEffect[] = [];
for (const p of prepared) {
if (!p.sheet) continue; // unknown/defeated target — skip defensively
if (skip?.(p)) continue;
p.sheet.resources[p.delta.resource] = p.after;
effects.push({
target: p.delta.target,
resource: p.delta.resource,
amount: p.amount,
before: p.before,
after: p.after,
});
}
return effects;
}
/** A prepared delta rendered as a *rejected* effect (nothing applied). */
function asRejected(p: PreparedDelta): ResolvedEffect {
return {
target: p.delta.target,
resource: p.delta.resource,
amount: p.amount,
before: p.before,
after: p.before,
};
}
/** Ids of combatants whose `endWhenZero` resource is at its floor (defeated). */
function findDefeated(
combatants: Record<string, Sheet>,
rules: RulesDefinition,
): string[] {
const defeated: string[] = [];
for (const [id, sheet] of Object.entries(combatants)) {
const down = Object.entries(rules.resources).some(
([key, def]) => def.endWhenZero && (sheet.resources[key] ?? def.default) <= def.min,
);
if (down) defeated.push(id);
}
return defeated;
}
/** True when the player's `endWhenZero` resource has reached its floor. */
function isGameOver(player: Sheet, rules: RulesDefinition): boolean {
return Object.entries(rules.resources).some(
([key, def]) => def.endWhenZero && (player.resources[key] ?? def.default) <= def.min,
);
}
/** Resolve a move. `move` may be null (a plain narrative pick → no-op roll). */
export function resolveMove(
move: Move | null,
actors: Actors,
dice: { d1: number; d2: number },
rng: Rng,
rules: RulesDefinition,
thresholds: Thresholds = DEFAULT_THRESHOLDS,
): Resolution {
// Fresh copies — never mutate the caller's sheets.
const player = cloneSheet(actors.player);
const combatants: Record<string, Sheet> = {};
for (const [id, s] of Object.entries(actors.combatants)) {
combatants[id] = cloneSheet(s);
}
// All flat bonuses (stat + difficulty + opposition) come from the shared
// `rollModifiers` so the resolution block and the odds preview never drift from
// what is actually rolled. A statless move is a certain transaction (no dice).
const m = rollModifiers(move, { player, combatants });
const { noRoll } = m;
const total = dice.d1 + dice.d2 + m.bonus;
const tier: Tier = noRoll ? "hit" : tierFor(total, thresholds);
const deltas: Delta[] = move ? move[tier] ?? [] : [];
const prepared = prepareDeltas(player, combatants, deltas, rng, rules);
// A CERTAIN transaction (no dice) the player can't cover fails wholesale — no
// money out, no goods in. A gamble (rolled move) is the player's risk: its
// deltas clamp as before (you can lose your last coin on a bad roll).
let effects: ResolvedEffect[] = [];
let rejected: ResolvedEffect[] = [];
let affordable = true;
if (noRoll && prepared.some(isUnaffordable)) {
affordable = false;
rejected = prepared.map(asRejected);
} else {
effects = commit(prepared);
}
const defeated = findDefeated(combatants, rules);
for (const id of defeated) delete combatants[id];
const gameOver = isGameOver(player, rules);
return {
stat: m.stat,
noRoll,
d1: dice.d1,
d2: dice.d2,
modifier: m.modifier,
difficultyModifier: m.difficultyModifier,
opposingModifier: m.opposingModifier,
opposedBy: m.opposedBy,
opposingStat: m.opposingStat,
opposingLabel: m.opposingLabel,
total,
tier,
effects,
rejected,
affordable,
defeated,
gameOver,
actors: { player, combatants },
};
}
/** The outcome of applying a deterministic, roll-free delta list. */
export interface AppliedEffects {
effects: ResolvedEffect[];
/** Spends the player couldn't afford — dropped (per-delta), not applied. */
rejected: ResolvedEffect[];
/** Combatant ids removed (their `endWhenZero` resource hit its floor). */
defeated: string[];
/** True when the player's `endWhenZero` resource reached its floor. */
gameOver: boolean;
/** Post-application actors (fresh copies; inputs are not mutated). */
actors: Actors;
}
/**
* Apply a flat list of deltas to the actors with no roll and no tier — the
* deterministic path for an emergent resource change the narration just
* described in the current reply (a paid price, a found coin). Mirrors
* `resolveMove`'s application/clamp/defeat logic, sans dice.
*
* Affordability is gated **per delta** here (unlike a move's atomic gate): an
* unaffordable spend is simply dropped while the rest still apply, since a delta
* list can bundle independent consequences (damage landed AND a coin found) that
* shouldn't all fail because one cost can't be paid.
*
* Pure: clones first, never mutates the caller's sheets.
*/
export function applyEffects(
actors: Actors,
deltas: Delta[],
rng: Rng,
rules: RulesDefinition,
): AppliedEffects {
const player = cloneSheet(actors.player);
const combatants: Record<string, Sheet> = {};
for (const [id, s] of Object.entries(actors.combatants)) {
combatants[id] = cloneSheet(s);
}
const prepared = prepareDeltas(player, combatants, deltas, rng, rules);
const effects = commit(prepared, isUnaffordable);
const rejected = prepared.filter((p) => p.sheet && isUnaffordable(p)).map(asRejected);
const defeated = findDefeated(combatants, rules);
for (const id of defeated) delete combatants[id];
const gameOver = isGameOver(player, rules);
return { effects, rejected, defeated, gameOver, actors: { player, combatants } };
}