/**
* Combatant spawning (Phase G). Pure instantiation of enemy sheets from the
* universe's declared archetypes. The model only *names* an archetype; the
* engine fills every number here, so a dynamically introduced foe never carries
* model-invented stats (locked decision 1).
*/
import { Sheet } from "../state/schema.js";
import { Rng, rollExpr } from "./dice.js";
import { RulesDefinition } from "./schema.js";
import { sheetKey } from "./sheetgen.js";
/**
* Resolve one archetype stat/resource value: a fixed number passes through
* unchanged; a dice-expression string (`"1d3+1"`) is rolled once with the
* injected rng (so it varies per spawn); anything absent falls back to the def's
* default. Garbage strings → `rollExpr` returns 0 → the value floors to the
* bound below.
*/
function rollArchetypeValue(raw: number | string | undefined, fallback: number, rng: Rng): number {
if (typeof raw === "number") return raw;
if (typeof raw === "string" && raw.trim()) return rollExpr(raw, rng);
return fallback;
}
/** Clamp to `[min, max]`; a null/undefined `max` means unbounded above. */
function clampToBounds(value: number, min: number | undefined, max: number | null | undefined): number {
let v = value;
if (typeof min === "number") v = Math.max(min, v);
if (typeof max === "number") v = Math.min(max, v);
return v;
}
/** A request to bring a combatant into the scene. */
export interface SpawnRequest {
/** Combatant id, e.g. "bandit-1" — the key under `state.combatants`. */
id: string;
/** Archetype name to instantiate; falls back to `_default` if unknown. */
archetype: string;
/** Optional display label overriding the archetype's. */
label?: string;
}
/**
* Instantiate a combatant sheet from an archetype. Unknown archetype ⇒ the
* `_default` fallback; if even that is absent, seed from the rules' resource/
* stat defaults so a spawn never throws.
*/
export function instantiateArchetype(
rules: RulesDefinition,
name: string,
/**
* Randomness for dice-expression archetype values. Injected like everywhere
* else in `rules/` (determinism + testability). Defaults to the floor roller
* (`() => 0` → every die its minimum face), so a call without an rng is
* deterministic and a fixed-number archetype is byte-identical to before.
*/
rng: Rng = () => 0,
): Sheet {
const arch = rules.archetypes[name] ?? rules.archetypes["_default"];
const stats: Record<string, number> = {};
for (const [key, def] of Object.entries(rules.stats)) {
const v = rollArchetypeValue(arch?.stats?.[key], def.default, rng);
stats[key] = clampToBounds(v, def.min, def.max);
}
const resources: Record<string, number> = {};
for (const [key, def] of Object.entries(rules.resources)) {
const v = rollArchetypeValue(arch?.resources?.[key], def.default, rng);
resources[key] = clampToBounds(v, def.min, def.max);
}
return {
initialized: true,
label: arch?.label || name,
stats,
resources,
};
}
/**
* Apply spawn requests to the combatant map, returning a new map. Requests with
* an empty id are skipped; an id **already in the scene is left untouched** (we
* never reset a tracked enemy's health by re-spawning it — the model is told to
* reuse existing ids rather than re-declare them).
*
* When `namedSheets` is supplied (keyed by {@link sheetKey}), a spawn whose
* archetype name OR label matches a known character uses that **card-derived**
* sheet instead of a generic archetype — so a named NPC entering the fight
* carries stats that fit their description (Phase G sheet generation). The
* generic-foe path (bandit, wolf, `_default`) is unchanged.
*/
export function applySpawns(
combatants: Record<string, Sheet>,
requests: SpawnRequest[],
rules: RulesDefinition,
namedSheets?: Record<string, Sheet>,
/** Randomness for dice-expression archetype values; floor roller by default. */
rng: Rng = () => 0,
): Record<string, Sheet> {
const out: Record<string, Sheet> = { ...combatants };
for (const req of requests) {
if (!req.id || out[req.id]) continue;
const named =
namedSheets &&
(namedSheets[sheetKey(req.archetype)] ??
(req.label ? namedSheets[sheetKey(req.label)] : undefined));
let sheet: Sheet;
if (named) {
// Clone so a later mutation never bleeds back into the cached card sheet.
sheet = {
...named,
stats: { ...named.stats },
resources: { ...named.resources },
};
if (req.label) sheet.label = req.label;
else if (!sheet.label) sheet.label = req.archetype;
} else {
sheet = instantiateArchetype(rules, req.archetype, rng);
if (req.label) sheet.label = req.label;
}
out[req.id] = sheet;
}
return out;
}
/**
* Deterministic roster hygiene — the safety net for unreliable models that
* re-narrate (and thus re-spawn) the same enemies under fresh ids every turn,
* inflating the combatant list. Two passes, both keeping the most-wounded
* instance so a fight stays coherent:
*
* 1. **Dedupe by label** (case-insensitive, trimmed): "Colosse" listed three
* times collapses to one. Genuinely distinct foes get distinct labels from
* the model ("Matelot 1" vs "Matelot 2"), so they survive.
* 2. **Cap** at `maxCombatants` (0 = no cap): keep the most-wounded N.
*
* Combatants with an empty label can't be deduped and are kept as-is. Pure.
*/
export function tidyRoster(
combatants: Record<string, Sheet>,
rules: RulesDefinition,
maxCombatants = 8,
): Record<string, Sheet> {
const vitalKeys = Object.keys(rules.resources).filter(
(k) => rules.resources[k].endWhenZero,
);
const vital = (s: Sheet): number =>
vitalKeys.reduce((sum, k) => sum + (s.resources[k] ?? 0), 0);
// 1. Dedupe by label; the more-wounded (lower vital sum) instance wins.
const byLabel = new Map<string, [string, Sheet]>();
const unlabeled: Array<[string, Sheet]> = [];
for (const [id, sheet] of Object.entries(combatants)) {
const key = sheet.label.trim().toLowerCase();
if (!key) {
unlabeled.push([id, sheet]);
continue;
}
const existing = byLabel.get(key);
if (!existing || vital(sheet) < vital(existing[1])) byLabel.set(key, [id, sheet]);
}
let survivors: Array<[string, Sheet]> = [...unlabeled, ...byLabel.values()];
// 2. Cap: keep the most-wounded up to the limit.
if (maxCombatants > 0 && survivors.length > maxCombatants) {
survivors = survivors
.slice()
.sort((a, b) => vital(a[1]) - vital(b[1]))
.slice(0, maxCombatants);
}
const out: Record<string, Sheet> = {};
for (const [id, sheet] of survivors) out[id] = sheet;
return out;
}