Add the engine core: path-addressed state, data-driven turns
The planning docs commit to one thing above all: adding content must never
require touching the engine. This lays the foundation that makes that true.
All game state is addressable by dotted path (money, skills.negotiation,
relationships.boss.likes_you, flags.took_loan), and conditions and effects are
generic operations over those paths. The schema in the planning docs had
`requires: { skill, min }` and `effects: { money, skills, relationships }`,
both of which hardcode the state shape into the engine; the first gate wanting
a relationship threshold or a flag would have meant an engine change.
There is deliberately no "random event" category. Every turn filters the whole
event pool by stage and conditions and draws by weight, so a routine turn, a
rare interruption and the hard-times branch differ only in their data.
Also here, neither in the planning docs but both cheap and load-bearing:
- A seeded, serializable RNG. Runs replay exactly from seed plus choices, which
makes tests deterministic and playtest reports reproducible. The generator's
position is one uint32 living in the save.
- A load-time content validator. Undeclared paths, unknown character ids, ids
that cannot be path segments, events that can never fire and stages with no
fallback event are all caught on load instead of forty turns into a game.
The turn loop is a pure function, so a saved game resumes into exactly the run
it left — covered by a test that plays thirty turns, plays the same thirty with
a JSON round trip in the middle, and deep-equals the two.
72 tests, no dependencies. Reasoning recorded in docs/DECISIONS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
This commit is contained in:
co-authored by
Claude Opus 5
parent
6c7d31371e
commit
cc59b81155
@@ -0,0 +1,68 @@
|
||||
// Generic predicates over state paths.
|
||||
//
|
||||
// A condition is either a comparison — { path, op, value } — or a combinator:
|
||||
// { all: [...] }, { any: [...] }, { not: {...} }. Content uses these for
|
||||
// option gates, event triggers and anything else that needs to ask a question
|
||||
// about state. The engine never learns what the paths mean.
|
||||
|
||||
import { getPath } from './paths.js';
|
||||
|
||||
const COMPARATORS = {
|
||||
'==': (a, b) => a === b,
|
||||
'!=': (a, b) => a !== b,
|
||||
'>': (a, b) => typeof a === 'number' && typeof b === 'number' && a > b,
|
||||
'>=': (a, b) => typeof a === 'number' && typeof b === 'number' && a >= b,
|
||||
'<': (a, b) => typeof a === 'number' && typeof b === 'number' && a < b,
|
||||
'<=': (a, b) => typeof a === 'number' && typeof b === 'number' && a <= b,
|
||||
'in': (a, b) => Array.isArray(b) && b.includes(a),
|
||||
'not-in': (a, b) => Array.isArray(b) && !b.includes(a),
|
||||
'has': (a, b) => Array.isArray(a) && a.includes(b),
|
||||
'not-has': (a, b) => Array.isArray(a) && !a.includes(b),
|
||||
};
|
||||
|
||||
export const OPERATORS = Object.keys(COMPARATORS);
|
||||
|
||||
/** True if `condition` holds against `state`. */
|
||||
export function evaluate(state, condition) {
|
||||
if (condition === null || typeof condition !== 'object') {
|
||||
throw new TypeError(`condition must be an object, got ${JSON.stringify(condition)}`);
|
||||
}
|
||||
if (Array.isArray(condition)) return condition.every((c) => evaluate(state, c));
|
||||
if ('all' in condition) return condition.all.every((c) => evaluate(state, c));
|
||||
if ('any' in condition) return condition.any.some((c) => evaluate(state, c));
|
||||
if ('not' in condition) return !evaluate(state, condition.not);
|
||||
|
||||
const { path, op, value } = condition;
|
||||
const compare = COMPARATORS[op];
|
||||
if (!compare) throw new TypeError(`unknown condition operator ${JSON.stringify(op)}`);
|
||||
return compare(getPath(state, path), value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a list of conditions, returning the ones that failed. An empty or
|
||||
* absent list passes. The failures are what the UI greys out an option with
|
||||
* and what the feedback log records as "the player could not pick this".
|
||||
*/
|
||||
export function check(state, conditions) {
|
||||
if (!conditions) return { passed: true, failed: [] };
|
||||
const list = Array.isArray(conditions) ? conditions : [conditions];
|
||||
const failed = list.filter((condition) => !evaluate(state, condition));
|
||||
return { passed: failed.length === 0, failed };
|
||||
}
|
||||
|
||||
/** Every path a condition tree reads. Used by the validator. */
|
||||
export function pathsUsed(condition, out = []) {
|
||||
if (condition === null || typeof condition !== 'object') return out;
|
||||
if (Array.isArray(condition)) {
|
||||
for (const c of condition) pathsUsed(c, out);
|
||||
} else if ('all' in condition) {
|
||||
for (const c of condition.all) pathsUsed(c, out);
|
||||
} else if ('any' in condition) {
|
||||
for (const c of condition.any) pathsUsed(c, out);
|
||||
} else if ('not' in condition) {
|
||||
pathsUsed(condition.not, out);
|
||||
} else if (typeof condition.path === 'string') {
|
||||
out.push(condition.path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Vendored
+62
@@ -0,0 +1,62 @@
|
||||
// Generic mutations over state paths.
|
||||
//
|
||||
// An effect is { path, op, value }. Applying a list returns a new state plus
|
||||
// a change record — the before/after of every path touched. That record is
|
||||
// what the feedback log stores, so "what did this choice actually do" is
|
||||
// answerable after the fact without replaying the run.
|
||||
|
||||
import { getPath, setPath } from './paths.js';
|
||||
|
||||
const OPS = {
|
||||
add: (current, value) => (typeof current === 'number' ? current : 0) + value,
|
||||
set: (_current, value) => value,
|
||||
push: (current, value) => [...(Array.isArray(current) ? current : []), value],
|
||||
remove: (current, value) => (Array.isArray(current) ? current.filter((v) => v !== value) : []),
|
||||
};
|
||||
|
||||
export const OPERATIONS = Object.keys(OPS);
|
||||
|
||||
/** Constrain a value to the bounds its schema entry declares, if any. */
|
||||
function clamp(value, rule) {
|
||||
if (!rule || typeof value !== 'number') return value;
|
||||
let out = value;
|
||||
if (typeof rule.min === 'number') out = Math.max(rule.min, out);
|
||||
if (typeof rule.max === 'number') out = Math.min(rule.max, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one effect. `schema` is the resolved state schema, consulted only for
|
||||
* clamping — the engine still has no idea what any given path means.
|
||||
*/
|
||||
export function applyEffect(state, effect, schema = {}) {
|
||||
const { path, op, value } = effect;
|
||||
const operate = OPS[op];
|
||||
if (!operate) throw new TypeError(`unknown effect operation ${JSON.stringify(op)}`);
|
||||
|
||||
const from = getPath(state, path);
|
||||
const raw = operate(from, value);
|
||||
const to = clamp(raw, schema[path]);
|
||||
|
||||
return {
|
||||
state: setPath(state, path, to),
|
||||
change: { path, op, value, from, to, clamped: raw !== to },
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply a list of effects in order. Returns the new state and every change. */
|
||||
export function applyEffects(state, effects, schema = {}) {
|
||||
let next = state;
|
||||
const changes = [];
|
||||
for (const effect of effects ?? []) {
|
||||
const result = applyEffect(next, effect, schema);
|
||||
next = result.state;
|
||||
changes.push(result.change);
|
||||
}
|
||||
return { state: next, changes };
|
||||
}
|
||||
|
||||
/** Every path a list of effects writes. Used by the validator. */
|
||||
export function pathsUsed(effects) {
|
||||
return (effects ?? []).map((effect) => effect.path).filter((p) => typeof p === 'string');
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// The turn loop.
|
||||
//
|
||||
// Purely functional: a turn is (pack, state, optionId) -> new state. The RNG
|
||||
// is rebuilt from the seed and position carried in state, so a saved game
|
||||
// resumes into exactly the run it left, and a test can replay a whole career
|
||||
// with no hidden state anywhere.
|
||||
|
||||
import { createRng } from './rng.js';
|
||||
import { applyEffects } from './effects.js';
|
||||
import { resolveSchema, createState } from './state.js';
|
||||
import { selectEvent, resolveOptions, findOption } from './select.js';
|
||||
import { setPath, getPath } from './paths.js';
|
||||
|
||||
export class ContentError extends Error {}
|
||||
export class MoveError extends Error {}
|
||||
|
||||
/** Draw the next event and record where the generator ended up. */
|
||||
function advance(pack, state) {
|
||||
const rng = createRng(state.meta.seed, state.meta.rngPosition ?? undefined);
|
||||
const event = selectEvent(pack, state, rng);
|
||||
if (!event) {
|
||||
throw new ContentError(
|
||||
`no event is eligible at stage "${state.stage}" on turn ${state.turn}; ` +
|
||||
'every stage needs at least one event with no conditions',
|
||||
);
|
||||
}
|
||||
let next = setPath(state, 'meta.currentEventId', event.id);
|
||||
next = setPath(next, 'meta.rngPosition', rng.getPosition());
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Begin a run. `seed` is any string or number; it is stored in the save. */
|
||||
export function startGame(pack, { seed = Date.now(), schema = resolveSchema(pack), now = null } = {}) {
|
||||
let state = createState(pack, { seed, schema });
|
||||
state = setPath(state, 'meta.startedAt', now);
|
||||
return advance(pack, state);
|
||||
}
|
||||
|
||||
/** The event the player is looking at. */
|
||||
export function currentEvent(pack, state) {
|
||||
const id = getPath(state, 'meta.currentEventId');
|
||||
const event = (pack.events ?? []).find((e) => e.id === id);
|
||||
if (!event) throw new ContentError(`current event "${id}" is not in pack "${pack.id}"`);
|
||||
return event;
|
||||
}
|
||||
|
||||
/** The current event together with each option's availability. */
|
||||
export function currentTurn(pack, state) {
|
||||
const event = currentEvent(pack, state);
|
||||
return { event, options: resolveOptions(state, event) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Play one option. Returns the new state and a record of what happened —
|
||||
* the record is what the feedback log stores, and it deliberately includes the
|
||||
* options that were locked, not just the one that was taken.
|
||||
*/
|
||||
export function takeTurn(pack, state, optionId, { schema = resolveSchema(pack), now = null } = {}) {
|
||||
const event = currentEvent(pack, state);
|
||||
const option = findOption(event, optionId);
|
||||
if (!option) throw new MoveError(`event "${event.id}" has no option "${optionId}"`);
|
||||
|
||||
const resolved = resolveOptions(state, event);
|
||||
const chosen = resolved.find((r) => r.option.id === optionId);
|
||||
if (!chosen.available) {
|
||||
throw new MoveError(`option "${optionId}" is locked: ${JSON.stringify(chosen.failed)}`);
|
||||
}
|
||||
|
||||
const applied = applyEffects(state, option.effects, schema);
|
||||
let next = applied.state;
|
||||
|
||||
const seenCount = getPath(next, `seen.${event.id}`) ?? 0;
|
||||
next = setPath(next, `seen.${event.id}`, seenCount + 1);
|
||||
next = setPath(next, `lastSeen.${event.id}`, next.turn);
|
||||
|
||||
const record = {
|
||||
turn: next.turn,
|
||||
stage: next.stage,
|
||||
eventId: event.id,
|
||||
optionId: option.id,
|
||||
optionLabel: option.label,
|
||||
changes: applied.changes,
|
||||
offered: resolved.map((r) => ({
|
||||
id: r.option.id,
|
||||
label: r.option.label,
|
||||
available: r.available,
|
||||
failed: r.failed,
|
||||
})),
|
||||
at: now,
|
||||
};
|
||||
|
||||
next = setPath(next, 'history', [...next.history, record]);
|
||||
next = setPath(next, 'turn', next.turn + 1);
|
||||
next = advance(pack, next);
|
||||
|
||||
return { state: next, record };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// The engine's public surface. Nothing in src/engine may import from content/.
|
||||
export { getPath, setPath, hasPath, parsePath, listPaths } from './paths.js';
|
||||
export { createRng } from './rng.js';
|
||||
export { evaluate, check, OPERATORS } from './conditions.js';
|
||||
export { applyEffect, applyEffects, OPERATIONS } from './effects.js';
|
||||
export { createState, resolveSchema, indexCharacters, RESERVED_ROOTS } from './state.js';
|
||||
export { eligibleEvents, selectEvent, resolveOptions, findOption } from './select.js';
|
||||
export { startGame, currentEvent, currentTurn, takeTurn, ContentError, MoveError } from './game.js';
|
||||
export { validatePack, assertValidPack } from './validate.js';
|
||||
@@ -0,0 +1,83 @@
|
||||
// Path addressing for game state.
|
||||
//
|
||||
// Every piece of game state is reachable by a dotted path string: `money`,
|
||||
// `skills.negotiation`, `relationships.boss.likes_you`, `flags.took_loan`.
|
||||
// Conditions, effects, gates and event triggers are all expressed against
|
||||
// these paths, which is what lets content reach any part of state without the
|
||||
// engine knowing what that part means.
|
||||
|
||||
const SEGMENT = /^[A-Za-z0-9_]+$/;
|
||||
const FORBIDDEN = new Set(['__proto__', 'prototype', 'constructor']);
|
||||
|
||||
/** Split a path into segments, throwing on anything malformed or unsafe. */
|
||||
export function parsePath(path) {
|
||||
if (typeof path !== 'string' || path.length === 0) {
|
||||
throw new TypeError(`path must be a non-empty string, got ${JSON.stringify(path)}`);
|
||||
}
|
||||
const segments = path.split('.');
|
||||
for (const segment of segments) {
|
||||
if (!SEGMENT.test(segment)) {
|
||||
throw new TypeError(`invalid path segment ${JSON.stringify(segment)} in "${path}"`);
|
||||
}
|
||||
// Content is data, and data can be authored by anyone; never let a path
|
||||
// walk into the prototype chain.
|
||||
if (FORBIDDEN.has(segment)) {
|
||||
throw new TypeError(`forbidden path segment ${JSON.stringify(segment)} in "${path}"`);
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Read a path. Returns undefined if any link in the chain is missing. */
|
||||
export function getPath(state, path) {
|
||||
let cursor = state;
|
||||
for (const segment of parsePath(path)) {
|
||||
if (cursor === null || typeof cursor !== 'object') return undefined;
|
||||
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return undefined;
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
/** True if the path exists (distinct from resolving to undefined). */
|
||||
export function hasPath(state, path) {
|
||||
let cursor = state;
|
||||
for (const segment of parsePath(path)) {
|
||||
if (cursor === null || typeof cursor !== 'object') return false;
|
||||
if (!Object.prototype.hasOwnProperty.call(cursor, segment)) return false;
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a path, returning a new state. Ancestors along the path are copied;
|
||||
* everything else is shared, so this is cheap to call in a loop.
|
||||
*/
|
||||
export function setPath(state, path, value) {
|
||||
const segments = parsePath(path);
|
||||
const root = { ...state };
|
||||
let cursor = root;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const segment = segments[i];
|
||||
const child = cursor[segment];
|
||||
cursor[segment] = (child !== null && typeof child === 'object') ? { ...child } : {};
|
||||
cursor = cursor[segment];
|
||||
}
|
||||
cursor[segments[segments.length - 1]] = value;
|
||||
return root;
|
||||
}
|
||||
|
||||
/** Every leaf path in an object, in depth-first order. Used by the validator. */
|
||||
export function listPaths(object, prefix = '') {
|
||||
const out = [];
|
||||
for (const [key, value] of Object.entries(object)) {
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
out.push(...listPaths(value, path));
|
||||
} else {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Seeded, serializable random number generator.
|
||||
//
|
||||
// Every draw the game makes comes from here, so a run is fully reproducible
|
||||
// from its seed plus the player's choice sequence. That makes tests
|
||||
// deterministic and playtest reports replayable: the seed and the generator's
|
||||
// position both live in the save file and the feedback log.
|
||||
|
||||
/** Hash an arbitrary string seed down to a uint32. */
|
||||
function hashSeed(seed) {
|
||||
let h = 1779033703 ^ String(seed).length;
|
||||
for (let i = 0; i < String(seed).length; i++) {
|
||||
h = Math.imul(h ^ String(seed).charCodeAt(i), 3432918353);
|
||||
h = (h << 13) | (h >>> 19);
|
||||
}
|
||||
h = Math.imul(h ^ (h >>> 16), 2246822507);
|
||||
h = Math.imul(h ^ (h >>> 13), 3266489909);
|
||||
return (h ^ (h >>> 16)) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* mulberry32: one uint32 of state, which is the whole point — the generator's
|
||||
* position round-trips through JSON with no special handling.
|
||||
*/
|
||||
export function createRng(seed, position) {
|
||||
let state = position === undefined ? hashSeed(seed) : position >>> 0;
|
||||
|
||||
const next = () => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
|
||||
return {
|
||||
seed,
|
||||
/** Float in [0, 1). */
|
||||
next,
|
||||
/** Integer in [0, n). */
|
||||
int: (n) => Math.floor(next() * n),
|
||||
/** Uniform pick from a non-empty array. */
|
||||
pick(items) {
|
||||
if (!items.length) throw new RangeError('cannot pick from an empty array');
|
||||
return items[Math.floor(next() * items.length)];
|
||||
},
|
||||
/**
|
||||
* Weighted pick. `weightOf` must return a non-negative number; entries
|
||||
* weighing zero are never drawn. Returns undefined if nothing has weight.
|
||||
*/
|
||||
weighted(items, weightOf) {
|
||||
let total = 0;
|
||||
for (const item of items) total += Math.max(0, weightOf(item));
|
||||
if (total <= 0) return undefined;
|
||||
let roll = next() * total;
|
||||
for (const item of items) {
|
||||
roll -= Math.max(0, weightOf(item));
|
||||
if (roll < 0) return item;
|
||||
}
|
||||
return items[items.length - 1];
|
||||
},
|
||||
/** Current position, for saving mid-run. */
|
||||
getPosition: () => state,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Choosing what happens this turn.
|
||||
//
|
||||
// There is deliberately no such thing as "a scripted turn" versus "a random
|
||||
// event" in here. Every turn, the whole event pool is filtered by stage and
|
||||
// conditions, then drawn from by weight. A routine turn is an event with a
|
||||
// high weight and loose conditions; a rare interruption is one with a low
|
||||
// weight and tight conditions; the "hard times" branch is one whose
|
||||
// conditions include a money threshold. The engine branches on none of it.
|
||||
|
||||
import { check } from './conditions.js';
|
||||
import { getPath } from './paths.js';
|
||||
|
||||
/** Events that could fire right now, with the reason excluded ones could not. */
|
||||
export function eligibleEvents(pack, state) {
|
||||
const eligible = [];
|
||||
const excluded = [];
|
||||
|
||||
for (const event of pack.events ?? []) {
|
||||
if (event.stage !== undefined && event.stage !== state.stage) {
|
||||
excluded.push({ event, reason: 'stage' });
|
||||
continue;
|
||||
}
|
||||
const seenCount = getPath(state, `seen.${event.id}`) ?? 0;
|
||||
if (event.once && seenCount > 0) {
|
||||
excluded.push({ event, reason: 'once' });
|
||||
continue;
|
||||
}
|
||||
if (event.cooldown) {
|
||||
const lastTurn = getPath(state, `lastSeen.${event.id}`);
|
||||
if (lastTurn !== undefined && state.turn - lastTurn < event.cooldown) {
|
||||
excluded.push({ event, reason: 'cooldown' });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const gate = check(state, event.requires);
|
||||
if (!gate.passed) {
|
||||
excluded.push({ event, reason: 'requires', failed: gate.failed });
|
||||
continue;
|
||||
}
|
||||
eligible.push(event);
|
||||
}
|
||||
|
||||
return { eligible, excluded };
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw this turn's event. Returns undefined when nothing is eligible, which is
|
||||
* a content bug rather than a game state — the validator checks that at least
|
||||
* one unconditional event exists per stage so this cannot happen in practice.
|
||||
*/
|
||||
export function selectEvent(pack, state, rng) {
|
||||
const { eligible } = eligibleEvents(pack, state);
|
||||
return rng.weighted(eligible, (event) => event.weight ?? 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every option on an event, in authored order, marked available or locked and
|
||||
* carrying the conditions that locked it. The UI greys locked options out; the
|
||||
* feedback log records them, because what a player *could not* choose is the
|
||||
* strongest signal that the choice set is too narrow.
|
||||
*/
|
||||
export function resolveOptions(state, event) {
|
||||
return (event.options ?? []).map((option) => {
|
||||
const gate = check(state, option.requires);
|
||||
return {
|
||||
option,
|
||||
available: gate.passed,
|
||||
failed: gate.failed,
|
||||
hidden: !gate.passed && option.whenLocked === 'hide',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Look up one option on an event by id. */
|
||||
export function findOption(event, optionId) {
|
||||
return (event.options ?? []).find((option) => option.id === optionId);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
// Turning a content pack into a running game state.
|
||||
//
|
||||
// The pack declares what state exists and what it starts at; the engine
|
||||
// expands that into a flat schema and an initial state object. The engine
|
||||
// names no stat of its own beyond its own bookkeeping.
|
||||
|
||||
import { setPath } from './paths.js';
|
||||
|
||||
/**
|
||||
* Paths the engine owns. Content may *read* these in conditions — "only after
|
||||
* day 10", "only if this event hasn't fired" — but may not declare or write
|
||||
* them, which is what keeps engine bookkeeping from being corrupted by data.
|
||||
*/
|
||||
export const RESERVED_ROOTS = new Set(['turn', 'stage', 'seen', 'lastSeen', 'history', 'meta']);
|
||||
|
||||
/**
|
||||
* Flatten a pack's state declarations into one path -> rule map, expanding the
|
||||
* per-character relationship stats. `relationships.<characterId>.<stat>` is
|
||||
* generated from `relationshipStats` x `characters`, so adding a character
|
||||
* adds its relationship state automatically and naming a stat is the pack's
|
||||
* business, never the engine's.
|
||||
*/
|
||||
export function resolveSchema(pack) {
|
||||
const schema = {};
|
||||
for (const [path, rule] of Object.entries(pack.state ?? {})) {
|
||||
schema[path] = { ...rule };
|
||||
}
|
||||
for (const character of pack.characters ?? []) {
|
||||
for (const [stat, rule] of Object.entries(pack.relationshipStats ?? {})) {
|
||||
schema[`relationships.${character.id}.${stat}`] = { ...rule };
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
/** A fresh game state for `pack`, seeded for reproducibility. */
|
||||
export function createState(pack, { seed, schema = resolveSchema(pack) } = {}) {
|
||||
let state = {
|
||||
meta: {
|
||||
packId: pack.id,
|
||||
packVersion: pack.version ?? null,
|
||||
seed: String(seed),
|
||||
rngPosition: null,
|
||||
startedAt: null,
|
||||
},
|
||||
turn: 1,
|
||||
stage: pack.startingStage,
|
||||
seen: {},
|
||||
lastSeen: {},
|
||||
history: [],
|
||||
};
|
||||
for (const [path, rule] of Object.entries(schema)) {
|
||||
state = setPath(state, path, structuredClone(rule.initial));
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/** The characters a pack defines, keyed by id. */
|
||||
export function indexCharacters(pack) {
|
||||
return Object.fromEntries((pack.characters ?? []).map((c) => [c.id, c]));
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Load-time validation of a content pack.
|
||||
//
|
||||
// This is what makes "adding content is just adding data" safe. Without it, a
|
||||
// typo in a path or a character id fails silently forty turns into a
|
||||
// playthrough. Run it whenever a pack is loaded in development, and in tests.
|
||||
|
||||
import { RESERVED_ROOTS, resolveSchema, indexCharacters } from './state.js';
|
||||
import { OPERATORS, pathsUsed as conditionPaths } from './conditions.js';
|
||||
import { OPERATIONS, pathsUsed as effectPaths } from './effects.js';
|
||||
import { parsePath } from './paths.js';
|
||||
|
||||
const ID = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
/**
|
||||
* Check a pack. Returns { errors, warnings, schema }; errors mean the pack
|
||||
* cannot be played, warnings mean it probably does not do what was intended.
|
||||
*/
|
||||
export function validatePack(pack) {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
const schema = resolveSchema(pack);
|
||||
const characters = indexCharacters(pack);
|
||||
const stateRoots = new Set(Object.keys(schema).map((p) => p.split('.')[0]));
|
||||
|
||||
const fail = (where, message) => errors.push(`${where}: ${message}`);
|
||||
const warn = (where, message) => warnings.push(`${where}: ${message}`);
|
||||
|
||||
if (!pack.id || !ID.test(pack.id)) fail('pack', `id ${JSON.stringify(pack.id)} must match ${ID}`);
|
||||
if (!pack.startingStage) fail('pack', 'startingStage is required');
|
||||
|
||||
// Content may read engine bookkeeping but must never declare or write it.
|
||||
for (const path of Object.keys(schema)) {
|
||||
try {
|
||||
const root = parsePath(path)[0];
|
||||
if (RESERVED_ROOTS.has(root)) {
|
||||
fail('state', `"${path}" declares reserved root "${root}", which the engine owns`);
|
||||
}
|
||||
} catch (error) {
|
||||
fail('state', `"${path}" is not a usable path: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const seenCharacters = new Set();
|
||||
for (const character of pack.characters ?? []) {
|
||||
const where = `character "${character.id}"`;
|
||||
if (!character.id || !ID.test(character.id)) fail(where, `id must match ${ID}`);
|
||||
if (seenCharacters.has(character.id)) fail(where, 'duplicate id');
|
||||
seenCharacters.add(character.id);
|
||||
if (!character.name) fail(where, 'name is required');
|
||||
if (!character.role_type) warn(where, 'no role_type tag; content cannot filter on it');
|
||||
}
|
||||
|
||||
const readable = (path) => {
|
||||
let root;
|
||||
try {
|
||||
root = parsePath(path)[0];
|
||||
} catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
if (RESERVED_ROOTS.has(root)) return null;
|
||||
if (path in schema) return null;
|
||||
if (stateRoots.has(root)) return `"${path}" is not declared in pack.state or relationshipStats`;
|
||||
return `"${path}" is not declared anywhere in this pack`;
|
||||
};
|
||||
|
||||
const writable = (path) => {
|
||||
let root;
|
||||
try {
|
||||
root = parsePath(path)[0];
|
||||
} catch (error) {
|
||||
return error.message;
|
||||
}
|
||||
if (RESERVED_ROOTS.has(root)) return `"${path}" writes engine-owned state`;
|
||||
if (!(path in schema)) return `"${path}" is not declared in pack.state or relationshipStats`;
|
||||
return null;
|
||||
};
|
||||
|
||||
const checkConditions = (where, conditions) => {
|
||||
if (!conditions) return;
|
||||
const list = Array.isArray(conditions) ? conditions : [conditions];
|
||||
for (const path of list.flatMap((c) => conditionPaths(c))) {
|
||||
const problem = readable(path);
|
||||
if (problem) fail(where, `condition reads ${problem}`);
|
||||
}
|
||||
const walk = (condition) => {
|
||||
if (!condition || typeof condition !== 'object') return;
|
||||
if (Array.isArray(condition)) return condition.forEach(walk);
|
||||
if ('all' in condition) return condition.all.forEach(walk);
|
||||
if ('any' in condition) return condition.any.forEach(walk);
|
||||
if ('not' in condition) return walk(condition.not);
|
||||
if (!OPERATORS.includes(condition.op)) {
|
||||
fail(where, `unknown condition operator ${JSON.stringify(condition.op)}`);
|
||||
}
|
||||
};
|
||||
list.forEach(walk);
|
||||
};
|
||||
|
||||
const stages = new Set();
|
||||
const unconditional = new Set();
|
||||
const seenEvents = new Set();
|
||||
|
||||
for (const event of pack.events ?? []) {
|
||||
const where = `event "${event.id}"`;
|
||||
if (!event.id || !ID.test(event.id)) fail(where, `id must match ${ID} (it is used as a state path)`);
|
||||
if (seenEvents.has(event.id)) fail(where, 'duplicate id');
|
||||
seenEvents.add(event.id);
|
||||
if (!event.description) warn(where, 'no description; the player will see an empty scenario');
|
||||
if (event.stage !== undefined) stages.add(event.stage);
|
||||
if (event.weight !== undefined && !(event.weight > 0)) {
|
||||
fail(where, `weight ${event.weight} must be greater than zero, or the event can never fire`);
|
||||
}
|
||||
checkConditions(where, event.requires);
|
||||
// A fallback must be one that *no* exclusion rule can remove: requires,
|
||||
// once and cooldown are exactly the three reasons eligibleEvents drops an
|
||||
// event, so a floor event may carry none of them.
|
||||
if (!event.requires && !event.once && !event.cooldown && event.stage !== undefined) {
|
||||
unconditional.add(event.stage);
|
||||
}
|
||||
|
||||
const options = event.options ?? [];
|
||||
if (options.length === 0) fail(where, 'has no options; the turn would be unplayable');
|
||||
const seenOptions = new Set();
|
||||
for (const option of options) {
|
||||
const optionWhere = `${where} option "${option.id}"`;
|
||||
if (!option.id || !ID.test(option.id)) fail(optionWhere, `id must match ${ID}`);
|
||||
if (seenOptions.has(option.id)) fail(optionWhere, 'duplicate id');
|
||||
seenOptions.add(option.id);
|
||||
if (!option.label) fail(optionWhere, 'label is required');
|
||||
checkConditions(optionWhere, option.requires);
|
||||
|
||||
for (const effect of option.effects ?? []) {
|
||||
if (!OPERATIONS.includes(effect.op)) {
|
||||
fail(optionWhere, `unknown effect operation ${JSON.stringify(effect.op)}`);
|
||||
}
|
||||
if (['add'].includes(effect.op) && typeof effect.value !== 'number') {
|
||||
fail(optionWhere, `effect "${effect.path}" uses ${effect.op} with a non-numeric value`);
|
||||
}
|
||||
}
|
||||
for (const path of effectPaths(option.effects)) {
|
||||
const problem = writable(path);
|
||||
if (problem) fail(optionWhere, `effect writes ${problem}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every reachable stage needs a floor: one event with no conditions, or the
|
||||
// pool can empty out mid-run and the turn loop has nothing to show.
|
||||
if (pack.startingStage && !stages.has(pack.startingStage)) {
|
||||
fail('pack', `startingStage "${pack.startingStage}" has no events`);
|
||||
}
|
||||
for (const stage of stages) {
|
||||
if (!unconditional.has(stage)) {
|
||||
fail('events', `stage "${stage}" has no unconditional event; the turn pool can run dry`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [path, rule] of Object.entries(schema)) {
|
||||
if (rule.initial === undefined) fail('state', `"${path}" has no initial value`);
|
||||
if (typeof rule.initial === 'number' && typeof rule.min === 'number' && rule.initial < rule.min) {
|
||||
warn('state', `"${path}" starts below its own minimum`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(characters).length === 0) warn('pack', 'no characters defined');
|
||||
|
||||
return { errors, warnings, schema, valid: errors.length === 0 };
|
||||
}
|
||||
|
||||
/** Throw if the pack is unplayable. Call this at load time in development. */
|
||||
export function assertValidPack(pack) {
|
||||
const result = validatePack(pack);
|
||||
if (!result.valid) {
|
||||
throw new Error(`content pack "${pack.id}" is invalid:\n ${result.errors.join('\n ')}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user