/**
* Game-rules schema + tolerant normalization (Phase G — structured mechanics).
*
* A universe may ship an optional `rules.json` declaring the chiffré layer:
* which **stats** modify the dice, which **resources** (health, money, …) can
* be spent or earned, and which enemy **archetypes** the engine instantiates
* when a foe appears mid-scene. The save holds the live values; this file only
* describes the *shape* of a universe's rules.
*
* Pure and unit-testable (no I/O). Loading from disk lives in `load.ts`. Like
* the lore/world loaders, normalization is defensive: junk in → a usable result
* or `null`, never a throw. `null` means "no actionable rules" → mechanics off,
* so a universe without a `rules.json` (or with an empty one) behaves exactly as
* before this phase.
*
* Decoupled from `world`/`characters` (CLAUDE.md): `director` assembles it.
*/
import { z } from "zod";
/** A stat that modifies the 2d6 roll (PbtA stat line). */
export const StatDef = z.object({
/** Display label, e.g. "Might". */
label: z.string().default(""),
/**
* Localized / alternate prose names for this stat (harmless extra data carried
* in `rules.json`). The model narrates a stakes test in the player's language
* ("(Risque : tester votre grâce)"); aliases let a later pass map that wording
* back to the canonical stat key. Mirrors lore keyword aliases — useful for
* non-English play (see the French-play / English-keys note).
*/
aliases: z.array(z.string()).default([]),
/** Lower bound for the stat value. */
min: z.number().default(-3),
/** Upper bound for the stat value. */
max: z.number().default(3),
/** Starting value applied at character creation. */
default: z.number().default(0),
});
export type StatDef = z.infer<typeof StatDef>;
/** A depletable / accumulable pool, e.g. health or money. */
export const ResourceDef = z.object({
/** Display label, e.g. "Health". */
label: z.string().default(""),
/** Optional glyph for the status line, e.g. "❤". */
icon: z.string().default(""),
/** Floor (reaching it triggers `endWhenZero` effects). */
min: z.number().default(0),
/** Ceiling, or `null` for unbounded above (e.g. money). */
max: z.number().nullable().default(null),
/** Starting value. */
default: z.number().default(0),
/**
* When true, hitting `min` ends the adventure for the player's sheet, and
* marks a combatant defeated (removed from the fight).
*/
endWhenZero: z.boolean().default(false),
});
export type ResourceDef = z.infer<typeof ResourceDef>;
/**
* An enemy template the engine instantiates on demand when a foe enters the
* scene. The model only picks the archetype; the engine fills the numbers, so a
* dynamically-introduced foe never gets model-invented stats.
*/
export const Archetype = z.object({
/** Display label for the spawned combatant, e.g. "Bandit". */
label: z.string().default(""),
/**
* Starting stat values (keys should match the universe `stats`). Each value is
* either a **fixed number** (every spawn identical) or a **dice expression**
* string rolled once at instantiation (`"1d2"`, `"1d3+1"`), so otherwise-faceless
* foes vary — one thug is a little tougher than the next. The rolled value is
* clamped to the stat's `[min, max]`. Numbers are unchanged (back-compat).
*/
stats: z.record(z.union([z.number(), z.string()])).default({}),
/** Starting resource values (keys should match the universe `resources`); same
* fixed-number-or-dice-expression form as `stats`, clamped to the resource bounds. */
resources: z.record(z.union([z.number(), z.string()])).default({}),
});
export type Archetype = z.infer<typeof Archetype>;
/** A universe's full rules definition. */
export const RulesDefinition = z.object({
/** Resolution system; "pbta" for now (the field lets others plug in later). */
system: z.string().default("pbta"),
stats: z.record(StatDef).default({}),
resources: z.record(ResourceDef).default({}),
/** Enemy templates; a mandatory `_default` is recommended as the fallback. */
archetypes: z.record(Archetype).default({}),
});
export type RulesDefinition = z.infer<typeof RulesDefinition>;
/** Normalize a record of defs, dropping any value that can't be coerced. */
function normalizeDefs<S extends z.ZodTypeAny>(
raw: unknown,
schema: S,
): Record<string, z.infer<S>> {
const out: Record<string, z.infer<S>> = {};
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
const parsed = schema.safeParse(value ?? {});
if (parsed.success) out[key] = parsed.data;
}
}
return out;
}
/**
* Normalize an arbitrary parsed JSON value into a {@link RulesDefinition}, or
* `null` when there is nothing actionable (no stats and no resources) — which
* the loader treats as "mechanics off". Never throws: bad entries are dropped,
* mirroring `normalizeWorld` / `normalizeLorebook`.
*/
export function normalizeRules(raw: unknown): RulesDefinition | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const o = raw as Record<string, unknown>;
const stats = normalizeDefs(o.stats, StatDef);
const resources = normalizeDefs(o.resources, ResourceDef);
const archetypes = normalizeDefs(o.archetypes, Archetype);
// No stats and no resources → nothing the engine can do; behave as no-rules.
if (Object.keys(stats).length === 0 && Object.keys(resources).length === 0) {
return null;
}
const system =
typeof o.system === "string" && o.system.trim() ? o.system.trim() : "pbta";
return { system, stats, resources, archetypes };
}