diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..5162748 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# The Ladder + +A turn-based career simulation game that runs entirely in the browser. You +start at the bottom of an organisation and play turns — a day at a time — +making choices that move your money, your skills and your standing with the +people around you. There is no win state and no game over: a bad run means +worse options, not an ending, and you can stop whenever you like and read the +retrospective. + +The engine knows nothing about offices, mailrooms or bosses. A **setting is a +content pack** — characters, events, choices, stat names and tuning, all data. +`corporateladder` is the default pack; a Wild West or lemonade-stand pack would +be new data and no new code. + +## Status + +Engine core is built and tested. No UI yet. + +| Piece | State | +| --- | --- | +| Path-addressed state, conditions, effects | done | +| Seeded RNG, save/resume | done | +| Event selection, option gating | done | +| Turn loop | done | +| Content validator | done | +| Corporate Ladder content pack | not started | +| UI, save/export, feedback log | not started | + +## Running + +```sh +npm test # engine test suite (node's built-in runner, no dependencies) +npm run serve # static server on :8080 for development +``` + +There are no runtime dependencies and no build step for development. Node is +used only to run tests and, later, the single-file build. + +## Layout + +``` +src/engine/ the game engine — imports nothing from content/ + paths.js dotted-path get/set over state + conditions.js generic predicates: { path, op, value }, all/any/not + effects.js generic mutations: add/set/push/remove, with clamping + state.js pack -> schema and initial state + select.js which event fires this turn, which options are available + game.js the turn loop + rng.js seeded, serializable RNG + validate.js load-time content checking +content/ content packs (settings) +test/ engine tests, run against a fixture pack, never real content +docs/ DECISIONS.md — why things are the way they are +Planning/ the original design documents +``` + +**The rule that keeps this honest: nothing in `src/engine/` may import from +`content/`.** The engine names no stat, no character and no setting of its own. + +## How content works + +A pack declares what state exists and what it starts at, the relationship stats +every character carries, the characters, and the events. Everything else falls +out of that. + +```js +export default { + id: 'corporateladder', + startingStage: 'mailroom', + + state: { + 'money': { initial: 1200, min: -5000, max: 1000000 }, + 'skills.negotiation': { initial: 1, min: 0, max: 100 }, + 'flags.took_loan': { initial: false }, + }, + + // Expanded per character into relationships.., so adding a + // character adds its relationship state automatically. + relationshipStats: { + likes_you: { initial: 50, min: 0, max: 100 }, + fears_you: { initial: 50, min: 0, max: 100 }, + wants_to_help_you: { initial: 50, min: 0, max: 100 }, + }, + + characters: [ + { id: 'boss', name: '...', role_type: 'boss', description: '...' }, + ], + + events: [ + { + id: 'mail_run', + stage: 'mailroom', + weight: 100, // relative likelihood among eligible events + description: 'A cart of mail and two hours to move it.', + options: [ + { + id: 'hustle', + label: 'Get it done fast.', + requires: [{ path: 'skills.negotiation', op: '>=', value: 3 }], + effects: [ + { path: 'money', op: 'add', value: 40 }, + { path: 'relationships.boss.likes_you', op: 'add', value: 3 }, + ], + }, + ], + }, + ], +}; +``` + +An event may also carry `once: true`, a `cooldown` in turns, and `requires` +conditions of its own. There is no separate notion of a random event: every +turn draws from the whole eligible pool by weight. + +Conditions may read engine bookkeeping (`turn`, `stage`, `seen.`, +`lastSeen.`) as well as pack state. Effects may not write it. + +Run `validatePack(pack)` when loading in development — it catches undeclared +paths, unknown characters, unusable ids, unreachable events and stages with no +fallback event, all of which otherwise fail silently mid-playthrough. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..d46b108 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,109 @@ +# Architecture decisions + +Why the code is shaped the way it is. Append to this file as decisions are +made; the reasoning is worth more later than the conclusion. + +## 1. All game state is addressable by path + +Every value in a run — `money`, `skills.negotiation`, +`relationships.boss.likes_you`, `flags.took_loan` — is reachable by a dotted +path string, and conditions and effects are expressed against those paths. + +The planning docs' schema had `requires: { skill, min }` and +`effects: { money, skills, relationships }`. Both hardcode the state shape into +the engine: the first gate that wants a relationship threshold, a money floor +or a flag, and the first effect that touches a fourth kind of state, means +editing engine code. Path addressing costs one `getPath`/`setPath` pair and +removes the whole class of change. + +Everything else in the engine is built on this primitive: option gates, event +triggers, the soft-failure branch, and — later — promotion tiers. + +## 2. Conditions and effects are data, evaluated generically + +A condition is `{ path, op, value }` or a combinator (`all` / `any` / `not`). +An effect is `{ path, op, value }` with `add` / `set` / `push` / `remove`. +The engine knows the operators; it never knows what a path *means*. + +Bounds live in the pack's state declaration (`min` / `max`) and are applied by +the effect layer, so clamping is content's decision, not the engine's. + +## 3. There is no such thing as a "random event" + +Every turn, the engine filters the entire event pool by stage and conditions, +then draws by weight. A routine turn is a high-weight event with loose +conditions; a rare interruption is a low-weight one; the hard-times branch is +an event whose conditions include a money threshold. The engine branches on +none of it, which is what the planning docs asked for in +`03-claude-code-build-prompt.md` §3 — but achieved by deleting the category +rather than by carefully not special-casing it. + +Consequence: every stage needs at least one event that no exclusion rule can +remove (no `requires`, no `once`, no `cooldown`), or the pool can run dry +mid-run. The validator enforces this. + +## 4. The engine owns some state; content may read it but not write it + +`turn`, `stage`, `seen`, `lastSeen`, `history` and `meta` are engine +bookkeeping. Content can read them in conditions — "only after day 10", "only +if this event has never fired" — which is useful and safe. Writing them is a +validation error, so data cannot corrupt the loop. + +`seen.` is a count and `lastSeen.` a turn number, which is +why event ids must be valid path segments. + +## 5. Seeded, serializable RNG + +Not in the planning docs; added because it is nearly free and pays three ways: +tests are deterministic, playtest reports are reproducible from seed plus +choice sequence, and the feedback log becomes replayable rather than merely +descriptive. mulberry32 was chosen because its entire state is one uint32, so +the generator's position round-trips through JSON with no special handling. + +The seed and position live in `meta` inside the save file. Nothing about a run +exists outside its state object. + +## 6. The turn loop is a pure function + +`takeTurn(pack, state, optionId) -> { state, record }`. No mutable game object, +no hidden state. A saved game resumes into exactly the run it left — there is a +test that plays 30 turns straight, plays the same 30 turns with a JSON round +trip in the middle, and deep-equals the results. + +## 7. Content is validated at load, not discovered at play + +`validatePack` checks that every path referenced by a condition or effect is +declared, every character id resolves, ids are usable as path segments, no +event is unreachable, and every stage has a fallback. Without it, a typo in +content fails silently forty turns into a playthrough — the exact failure mode +that makes "just add data" unsafe for a non-programmer. + +## 8. Distribution: ES modules in dev, one inlined file to ship + +Chrome and Firefox block ES module imports and `fetch` over `file://`, so +"open the HTML file" and "modular JS with JSON content" are in direct conflict. +Resolved by serving during development (`npm run serve`) and inlining +everything into a single self-contained HTML file for distribution +(`npm run build`). + +Related: content is authored as `.js` modules exporting plain objects rather +than `.json`. Same data, but comments and multiline strings are available — +which matters a great deal for narrative text — and it sidesteps `fetch` +entirely. + +## 9. Tuning values live in the content pack + +Relationship stats run 0–100 starting at 50, clamped; typical choice effects +are a few points, so one choice matters but several turns of consistent +behaviour are needed to move someone. Money is dollars. Turn is a day. + +None of these are engine constants. They are declared in the pack manifest, so +retuning the game — or a pack that runs on weeks instead of days — touches no +code. + +## 10. Naming + +The product is **The Ladder**; the default content pack is **Corporate +Ladder**. The planning docs use "Corporate Ladder" as the working title for +both, which would have baked one setting into the product name, against the +explicit goal that corporate is one pack among many. diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac20434 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "theladder", + "version": "0.0.1", + "private": true, + "description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.", + "type": "module", + "scripts": { + "test": "node --test \"test/**/*.test.js\"", + "serve": "python3 -m http.server 8080", + "build": "node tools/build.js" + } +} diff --git a/src/engine/conditions.js b/src/engine/conditions.js new file mode 100644 index 0000000..fb3b577 --- /dev/null +++ b/src/engine/conditions.js @@ -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; +} diff --git a/src/engine/effects.js b/src/engine/effects.js new file mode 100644 index 0000000..bebf403 --- /dev/null +++ b/src/engine/effects.js @@ -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'); +} diff --git a/src/engine/game.js b/src/engine/game.js new file mode 100644 index 0000000..958ceb7 --- /dev/null +++ b/src/engine/game.js @@ -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 }; +} diff --git a/src/engine/index.js b/src/engine/index.js new file mode 100644 index 0000000..fa88be7 --- /dev/null +++ b/src/engine/index.js @@ -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'; diff --git a/src/engine/paths.js b/src/engine/paths.js new file mode 100644 index 0000000..7176264 --- /dev/null +++ b/src/engine/paths.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; +} diff --git a/src/engine/rng.js b/src/engine/rng.js new file mode 100644 index 0000000..e481eca --- /dev/null +++ b/src/engine/rng.js @@ -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, + }; +} diff --git a/src/engine/select.js b/src/engine/select.js new file mode 100644 index 0000000..6be8fa6 --- /dev/null +++ b/src/engine/select.js @@ -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); +} diff --git a/src/engine/state.js b/src/engine/state.js new file mode 100644 index 0000000..a4301c5 --- /dev/null +++ b/src/engine/state.js @@ -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..` 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])); +} diff --git a/src/engine/validate.js b/src/engine/validate.js new file mode 100644 index 0000000..b05a775 --- /dev/null +++ b/src/engine/validate.js @@ -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; +} diff --git a/test/conditions.test.js b/test/conditions.test.js new file mode 100644 index 0000000..fb30720 --- /dev/null +++ b/test/conditions.test.js @@ -0,0 +1,79 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { evaluate, check, pathsUsed } from '../src/engine/conditions.js'; + +const state = { + money: 100, + turn: 12, + skills: { talking: 3 }, + flags: { in_debt: false }, + notable: ['promoted'], + seen: { first_day: 1 }, +}; + +test('compares numbers', () => { + assert.equal(evaluate(state, { path: 'money', op: '>=', value: 100 }), true); + assert.equal(evaluate(state, { path: 'money', op: '>', value: 100 }), false); + assert.equal(evaluate(state, { path: 'skills.talking', op: '<', value: 5 }), true); +}); + +test('compares booleans and equality', () => { + assert.equal(evaluate(state, { path: 'flags.in_debt', op: '==', value: false }), true); + assert.equal(evaluate(state, { path: 'flags.in_debt', op: '!=', value: true }), true); +}); + +test('array membership works both directions', () => { + assert.equal(evaluate(state, { path: 'notable', op: 'has', value: 'promoted' }), true); + assert.equal(evaluate(state, { path: 'notable', op: 'not-has', value: 'fired' }), true); + assert.equal(evaluate(state, { path: 'skills.talking', op: 'in', value: [1, 2, 3] }), true); + assert.equal(evaluate(state, { path: 'skills.talking', op: 'not-in', value: [9] }), true); +}); + +test('ordering against a missing path is false, not a throw', () => { + assert.equal(evaluate(state, { path: 'skills.welding', op: '>=', value: 1 }), false); + assert.equal(evaluate(state, { path: 'skills.welding', op: '<', value: 1 }), false); +}); + +test('content can read engine bookkeeping', () => { + assert.equal(evaluate(state, { path: 'turn', op: '>=', value: 10 }), true); + assert.equal(evaluate(state, { path: 'seen.first_day', op: '>=', value: 1 }), true); + assert.equal(evaluate(state, { path: 'seen.never_fired', op: '>=', value: 1 }), false); +}); + +test('combinators nest', () => { + const condition = { + all: [ + { path: 'money', op: '>', value: 0 }, + { any: [{ path: 'skills.talking', op: '>=', value: 3 }, { path: 'flags.in_debt', op: '==', value: true }] }, + { not: { path: 'notable', op: 'has', value: 'fired' } }, + ], + }; + assert.equal(evaluate(state, condition), true); + assert.equal(evaluate({ ...state, money: -1 }, condition), false); +}); + +test('check reports which conditions failed', () => { + const result = check(state, [ + { path: 'money', op: '>=', value: 100 }, + { path: 'skills.talking', op: '>=', value: 9 }, + ]); + assert.equal(result.passed, false); + assert.equal(result.failed.length, 1); + assert.equal(result.failed[0].path, 'skills.talking'); +}); + +test('an absent gate passes', () => { + assert.equal(check(state, undefined).passed, true); + assert.equal(check(state, []).passed, true); +}); + +test('an unknown operator is a loud failure', () => { + assert.throws(() => evaluate(state, { path: 'money', op: '=~', value: 1 }), /unknown condition operator/); +}); + +test('pathsUsed walks the whole tree', () => { + const paths = pathsUsed({ + all: [{ path: 'money', op: '>', value: 0 }, { not: { path: 'flags.in_debt', op: '==', value: true } }], + }); + assert.deepEqual(paths.sort(), ['flags.in_debt', 'money']); +}); diff --git a/test/effects.test.js b/test/effects.test.js new file mode 100644 index 0000000..ad0ea5a --- /dev/null +++ b/test/effects.test.js @@ -0,0 +1,74 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { applyEffect, applyEffects } from '../src/engine/effects.js'; + +const schema = { + 'money': { initial: 0, min: -500, max: 1000 }, + 'skills.talking': { initial: 1, min: 0, max: 10 }, + 'flags.in_debt': { initial: false }, + 'notable': { initial: [] }, +}; +const state = { money: 100, skills: { talking: 1 }, flags: { in_debt: false }, notable: [] }; + +test('add and set produce a new state', () => { + const result = applyEffects(state, [ + { path: 'money', op: 'add', value: -30 }, + { path: 'flags.in_debt', op: 'set', value: true }, + ], schema); + assert.equal(result.state.money, 70); + assert.equal(result.state.flags.in_debt, true); + assert.equal(state.money, 100, 'the input state is never mutated'); +}); + +test('push appends to a list', () => { + const result = applyEffects(state, [{ path: 'notable', op: 'push', value: 'first day' }], schema); + assert.deepEqual(result.state.notable, ['first day']); + assert.deepEqual(state.notable, [], 'the input list is never mutated'); +}); + +test('remove drops matching entries', () => { + const seeded = { ...state, notable: ['a', 'b', 'a'] }; + const result = applyEffects(seeded, [{ path: 'notable', op: 'remove', value: 'a' }], schema); + assert.deepEqual(result.state.notable, ['b']); +}); + +test('values clamp to their declared bounds', () => { + const high = applyEffect(state, { path: 'skills.talking', op: 'add', value: 99 }, schema); + assert.equal(high.state.skills.talking, 10); + assert.equal(high.change.clamped, true); + + const low = applyEffect(state, { path: 'money', op: 'add', value: -9999 }, schema); + assert.equal(low.state.money, -500); +}); + +test('an unbounded path is not clamped', () => { + const result = applyEffect(state, { path: 'money', op: 'add', value: 10 }, {}); + assert.equal(result.state.money, 110); + assert.equal(result.change.clamped, false); +}); + +test('the change record carries before and after for every path touched', () => { + const result = applyEffects(state, [ + { path: 'money', op: 'add', value: 25 }, + { path: 'skills.talking', op: 'add', value: 1 }, + ], schema); + assert.deepEqual(result.changes.map((c) => [c.path, c.from, c.to]), [ + ['money', 100, 125], + ['skills.talking', 1, 2], + ]); +}); + +test('adding to a path that has never been set treats it as zero', () => { + const result = applyEffect({}, { path: 'money', op: 'add', value: 5 }, {}); + assert.equal(result.state.money, 5); +}); + +test('an unknown operation is a loud failure', () => { + assert.throws(() => applyEffect(state, { path: 'money', op: 'multiply', value: 2 }), /unknown effect operation/); +}); + +test('an empty effect list is a no-op', () => { + const result = applyEffects(state, [], schema); + assert.deepEqual(result.changes, []); + assert.equal(result.state, state); +}); diff --git a/test/fixtures/test-pack.js b/test/fixtures/test-pack.js new file mode 100644 index 0000000..f2f9e54 --- /dev/null +++ b/test/fixtures/test-pack.js @@ -0,0 +1,112 @@ +// A deliberately small pack that exercises every engine feature: gates, +// once-only events, cooldowns, weighted draws, a conditional branch and a +// clamped stat. Engine tests run against this, never against real content, so +// tuning the game cannot break the test suite. + +export const testPack = { + id: 'testpack', + name: 'Test Pack', + version: '1.0.0', + turnUnit: 'day', + startingStage: 'floor', + + state: { + 'money': { initial: 100, min: -500, max: 10000 }, + 'skills.talking': { initial: 1, min: 0, max: 10 }, + 'flags.in_debt': { initial: false }, + 'notable': { initial: [] }, + }, + + relationshipStats: { + likes_you: { initial: 50, min: 0, max: 100 }, + fears_you: { initial: 50, min: 0, max: 100 }, + }, + + characters: [ + { id: 'chief', name: 'The Chief', role_type: 'boss', description: 'Tall, tired.' }, + { id: 'pat', name: 'Pat', role_type: 'rival', description: 'Smiles too much.' }, + ], + + events: [ + { + id: 'routine', + stage: 'floor', + weight: 100, + description: 'Another ordinary shift.', + options: [ + { + id: 'work', + label: 'Put your head down and work.', + effects: [ + { path: 'money', op: 'add', value: 10 }, + { path: 'relationships.chief.likes_you', op: 'add', value: 2 }, + ], + }, + { + id: 'charm', + label: 'Talk your way into an easier job.', + requires: [{ path: 'skills.talking', op: '>=', value: 3 }], + effects: [{ path: 'skills.talking', op: 'add', value: 1 }], + }, + { + id: 'practice', + label: 'Chat with Pat all afternoon.', + effects: [ + { path: 'skills.talking', op: 'add', value: 1 }, + { path: 'money', op: 'add', value: -5 }, + { path: 'relationships.pat.likes_you', op: 'add', value: 3 }, + ], + }, + ], + }, + { + id: 'first_day', + stage: 'floor', + weight: 1000, + once: true, + description: 'Your first day.', + options: [ + { + id: 'arrive', + label: 'Arrive on time.', + effects: [{ path: 'notable', op: 'push', value: 'Showed up on time, once.' }], + }, + ], + }, + { + id: 'hard_times', + stage: 'floor', + weight: 1000, + requires: [{ path: 'money', op: '<', value: 0 }], + description: 'Rent is due and you do not have it.', + options: [ + { + id: 'loan', + label: 'Take the loan.', + effects: [ + { path: 'money', op: 'add', value: 200 }, + { path: 'flags.in_debt', op: 'set', value: true }, + ], + }, + { + id: 'beg', + label: 'Ask Pat for help.', + effects: [ + { path: 'money', op: 'add', value: 50 }, + { path: 'relationships.pat.fears_you', op: 'add', value: -10 }, + ], + }, + ], + }, + { + id: 'audit', + stage: 'floor', + weight: 5, + cooldown: 10, + description: 'An auditor is wandering the floor.', + options: [{ id: 'nod', label: 'Nod politely.', effects: [] }], + }, + ], +}; + +export default testPack; diff --git a/test/game.test.js b/test/game.test.js new file mode 100644 index 0000000..54d8ea9 --- /dev/null +++ b/test/game.test.js @@ -0,0 +1,165 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { startGame, currentTurn, takeTurn, MoveError } from '../src/engine/game.js'; +import { getPath } from '../src/engine/paths.js'; +import { testPack } from './fixtures/test-pack.js'; + +/** Play `turns` turns, always taking the first available option. */ +function playThrough(seed, turns, chooser = (options) => options.find((o) => o.available).option.id) { + let state = startGame(testPack, { seed }); + const records = []; + for (let i = 0; i < turns; i++) { + const turn = currentTurn(testPack, state); + const result = takeTurn(testPack, state, chooser(turn.options, turn.event, state)); + state = result.state; + records.push(result.record); + } + return { state, records }; +} + +test('a new game starts on turn one with the pack defaults', () => { + const state = startGame(testPack, { seed: 'start' }); + assert.equal(state.turn, 1); + assert.equal(state.stage, 'floor'); + assert.equal(state.money, 100); + assert.equal(state.skills.talking, 1); + assert.equal(state.relationships.chief.likes_you, 50); + assert.equal(state.relationships.pat.fears_you, 50); + assert.ok(state.meta.currentEventId, 'an event is waiting before the first turn'); +}); + +test('relationship state is generated per character, so adding one adds its stats', () => { + const state = startGame(testPack, { seed: 'chars' }); + assert.deepEqual(Object.keys(state.relationships).sort(), ['chief', 'pat']); + assert.deepEqual(Object.keys(state.relationships.chief).sort(), ['fears_you', 'likes_you']); +}); + +test('taking a turn applies effects and advances the clock', () => { + let state = startGame(testPack, { seed: 'turn' }); + const before = state.money; + const result = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id); + assert.equal(result.state.turn, 2); + assert.notEqual(result.state.meta.currentEventId, undefined); + assert.ok(result.state.money !== before || result.record.changes.length >= 0); +}); + +test('the same seed and the same choices replay identically', () => { + const a = playThrough('replay', 40); + const b = playThrough('replay', 40); + assert.deepEqual(a.state, b.state); + assert.deepEqual(a.records, b.records); +}); + +test('different seeds produce different runs', () => { + const a = playThrough('seed-a', 40); + const b = playThrough('seed-b', 40); + assert.notDeepEqual(a.records.map((r) => r.eventId), b.records.map((r) => r.eventId)); +}); + +test('a saved game resumes into exactly the run it left', () => { + const uninterrupted = playThrough('save', 30); + + let state = startGame(testPack, { seed: 'save' }); + for (let i = 0; i < 15; i++) { + const turn = currentTurn(testPack, state); + state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state; + } + // The whole save is one JSON round trip; nothing lives outside it. + state = JSON.parse(JSON.stringify(state)); + for (let i = 0; i < 15; i++) { + const turn = currentTurn(testPack, state); + state = takeTurn(testPack, state, turn.options.find((o) => o.available).option.id).state; + } + assert.deepEqual(state, uninterrupted.state); +}); + +test('a once-only event fires exactly once across a long run', () => { + const { records } = playThrough('once', 60); + assert.equal(records.filter((r) => r.eventId === 'first_day').length, 1); + assert.equal(records[0].eventId, 'first_day', 'its weight makes it the opener'); +}); + +test('a locked option cannot be played', () => { + const state = startGame(testPack, { seed: 'locked' }); + let probe = state; + // Walk to a turn that offers the gated option. + for (let i = 0; i < 10; i++) { + const turn = currentTurn(testPack, probe); + const charm = turn.options.find((o) => o.option.id === 'charm'); + if (charm) { + assert.equal(charm.available, false); + assert.throws(() => takeTurn(testPack, probe, 'charm'), MoveError); + return; + } + probe = takeTurn(testPack, probe, turn.options.find((o) => o.available).option.id).state; + } + assert.fail('the gated option never came up'); +}); + +test('an unknown option id is rejected', () => { + const state = startGame(testPack, { seed: 'bogus' }); + assert.throws(() => takeTurn(testPack, state, 'no_such_option'), /has no option/); +}); + +test('a skill grows from choices and eventually unlocks its gate', () => { + const talkative = (options) => { + const practice = options.find((o) => o.option.id === 'practice' && o.available); + return (practice ?? options.find((o) => o.available)).option.id; + }; + const { state } = playThrough('skill', 20, talkative); + assert.ok(state.skills.talking >= 3, `expected talking to grow, got ${state.skills.talking}`); +}); + +test('money going negative brings the hard-times branch into the pool', () => { + const spendy = (options) => { + const practice = options.find((o) => o.option.id === 'practice' && o.available); + return (practice ?? options.find((o) => o.available)).option.id; + }; + let state = startGame(testPack, { seed: 'broke' }); + let sawHardTimes = false; + for (let i = 0; i < 120; i++) { + const turn = currentTurn(testPack, state); + if (turn.event.id === 'hard_times') { sawHardTimes = true; break; } + state = takeTurn(testPack, state, spendy(turn.options, turn.event, state)).state; + } + assert.ok(state.money < 0 || sawHardTimes, 'the run should have gone broke'); + assert.ok(sawHardTimes, 'hard times should surface once money is negative — the game never ends'); +}); + +test('the turn record carries the diff and the options the player could not take', () => { + let state = startGame(testPack, { seed: 'record' }); + state = takeTurn(testPack, state, currentTurn(testPack, state).options[0].option.id).state; + + const turn = currentTurn(testPack, state); + const { record } = takeTurn(testPack, state, 'work'); + + assert.equal(record.eventId, turn.event.id); + assert.equal(record.optionId, 'work'); + assert.deepEqual(record.changes.map((c) => c.path), ['money', 'relationships.chief.likes_you']); + assert.equal(record.changes[0].from + 10, record.changes[0].to); + + const locked = record.offered.find((o) => o.id === 'charm'); + assert.equal(locked.available, false); + assert.equal(locked.failed[0].path, 'skills.talking'); +}); + +test('history accumulates every turn and is what the retrospective reads', () => { + const { state, records } = playThrough('history', 12); + assert.equal(state.history.length, 12); + assert.deepEqual(state.history, records); +}); + +test('a stat cannot be pushed past its declared bound', () => { + const { state } = playThrough('clamp', 80, (options) => { + const practice = options.find((o) => o.option.id === 'practice' && o.available); + return (practice ?? options.find((o) => o.available)).option.id; + }); + assert.ok(state.skills.talking <= 10, 'talking is capped at 10'); + assert.ok(state.relationships.pat.likes_you <= 100, 'relationship stats are capped at 100'); +}); + +test('the run never ends on its own', () => { + const { state } = playThrough('endless', 200); + assert.equal(state.turn, 201); + assert.ok(currentTurn(testPack, state).options.some((o) => o.available), 'there is always something to do'); +}); diff --git a/test/paths.test.js b/test/paths.test.js new file mode 100644 index 0000000..8fea5d0 --- /dev/null +++ b/test/paths.test.js @@ -0,0 +1,47 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { getPath, setPath, hasPath, parsePath, listPaths } from '../src/engine/paths.js'; + +const state = { money: 100, skills: { talking: 2 }, relationships: { chief: { likes_you: 50 } } }; + +test('reads nested paths', () => { + assert.equal(getPath(state, 'money'), 100); + assert.equal(getPath(state, 'skills.talking'), 2); + assert.equal(getPath(state, 'relationships.chief.likes_you'), 50); +}); + +test('missing paths read as undefined, not as a throw', () => { + assert.equal(getPath(state, 'skills.welding'), undefined); + assert.equal(getPath(state, 'nothing.at.all'), undefined); + assert.equal(hasPath(state, 'skills.welding'), false); + assert.equal(hasPath(state, 'skills.talking'), true); +}); + +test('writes without mutating the original', () => { + const next = setPath(state, 'skills.talking', 5); + assert.equal(next.skills.talking, 5); + assert.equal(state.skills.talking, 2, 'original state must be untouched'); + assert.equal(next.relationships, state.relationships, 'untouched branches are shared'); +}); + +test('creates missing intermediate objects', () => { + const next = setPath({}, 'flags.deep.nested', true); + assert.equal(next.flags.deep.nested, true); +}); + +test('rejects prototype-polluting paths', () => { + assert.throws(() => parsePath('__proto__.polluted'), /forbidden/); + assert.throws(() => parsePath('constructor'), /forbidden/); + assert.throws(() => setPath({}, 'a.prototype.b', 1), /forbidden/); +}); + +test('rejects malformed paths', () => { + assert.throws(() => parsePath(''), /non-empty/); + assert.throws(() => parsePath('money.'), /invalid path segment/); + assert.throws(() => parsePath('a b'), /invalid path segment/); + assert.throws(() => parsePath(42), /non-empty string/); +}); + +test('lists leaf paths', () => { + assert.deepEqual(listPaths(state), ['money', 'skills.talking', 'relationships.chief.likes_you']); +}); diff --git a/test/rng.test.js b/test/rng.test.js new file mode 100644 index 0000000..14f6e00 --- /dev/null +++ b/test/rng.test.js @@ -0,0 +1,59 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRng } from '../src/engine/rng.js'; + +test('the same seed produces the same sequence', () => { + const a = createRng('seed-one'); + const b = createRng('seed-one'); + const draws = Array.from({ length: 20 }, () => [a.next(), b.next()]); + for (const [x, y] of draws) assert.equal(x, y); +}); + +test('different seeds diverge', () => { + const a = Array.from({ length: 10 }, (_, i) => createRng('one').next()); + const b = Array.from({ length: 10 }, (_, i) => createRng('two').next()); + assert.notDeepEqual(a, b); +}); + +test('a generator resumes exactly from a saved position', () => { + const original = createRng('resume-me'); + for (let i = 0; i < 7; i++) original.next(); + const position = original.getPosition(); + + const resumed = createRng('resume-me', position); + const expected = Array.from({ length: 5 }, () => original.next()); + const actual = Array.from({ length: 5 }, () => resumed.next()); + assert.deepEqual(actual, expected); +}); + +test('a saved position survives a JSON round trip', () => { + const rng = createRng('json'); + rng.next(); + const revived = createRng('json', JSON.parse(JSON.stringify(rng.getPosition()))); + assert.equal(revived.next(), createRng('json', rng.getPosition()).next()); +}); + +test('draws stay in range', () => { + const rng = createRng('range'); + for (let i = 0; i < 500; i++) { + const value = rng.next(); + assert.ok(value >= 0 && value < 1); + assert.ok(rng.int(10) >= 0 && rng.int(10) < 10); + } +}); + +test('weighted picks respect weight and skip zero-weight entries', () => { + const rng = createRng('weighted'); + const items = [{ id: 'common', w: 99 }, { id: 'rare', w: 1 }, { id: 'never', w: 0 }]; + const counts = { common: 0, rare: 0, never: 0 }; + for (let i = 0; i < 2000; i++) counts[rng.weighted(items, (i2) => i2.w).id]++; + assert.equal(counts.never, 0, 'zero weight must never be drawn'); + assert.ok(counts.common > counts.rare * 10, `expected common to dominate, got ${JSON.stringify(counts)}`); + assert.ok(counts.rare > 0, 'a weight of 1 in 100 should still appear over 2000 draws'); +}); + +test('weighted returns undefined when nothing has weight', () => { + const rng = createRng('empty'); + assert.equal(rng.weighted([{ w: 0 }], (i) => i.w), undefined); + assert.equal(rng.weighted([], () => 1), undefined); +}); diff --git a/test/select.test.js b/test/select.test.js new file mode 100644 index 0000000..fe8c47c --- /dev/null +++ b/test/select.test.js @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { eligibleEvents, selectEvent, resolveOptions } from '../src/engine/select.js'; +import { createState, resolveSchema } from '../src/engine/state.js'; +import { createRng } from '../src/engine/rng.js'; +import { setPath } from '../src/engine/paths.js'; +import { testPack } from './fixtures/test-pack.js'; + +const schema = resolveSchema(testPack); +const fresh = () => createState(testPack, { seed: 'select', schema }); + +test('a conditional event is out of the pool until its condition holds', () => { + const rich = fresh(); + assert.ok(!eligibleEvents(testPack, rich).eligible.some((e) => e.id === 'hard_times')); + + const broke = setPath(rich, 'money', -20); + assert.ok(eligibleEvents(testPack, broke).eligible.some((e) => e.id === 'hard_times')); +}); + +test('exclusions say why', () => { + const { excluded } = eligibleEvents(testPack, fresh()); + const hardTimes = excluded.find((x) => x.event.id === 'hard_times'); + assert.equal(hardTimes.reason, 'requires'); + assert.equal(hardTimes.failed[0].path, 'money'); +}); + +test('a once-only event leaves the pool after it fires', () => { + const state = setPath(fresh(), 'seen.first_day', 1); + const { eligible, excluded } = eligibleEvents(testPack, state); + assert.ok(!eligible.some((e) => e.id === 'first_day')); + assert.equal(excluded.find((x) => x.event.id === 'first_day').reason, 'once'); +}); + +test('a cooldown holds an event back for the declared number of turns', () => { + let state = fresh(); + state = setPath(state, 'lastSeen.audit', 5); + + state = setPath(state, 'turn', 9); + assert.equal(eligibleEvents(testPack, state).excluded.find((x) => x.event.id === 'audit')?.reason, 'cooldown'); + + state = setPath(state, 'turn', 15); + assert.ok(eligibleEvents(testPack, state).eligible.some((e) => e.id === 'audit')); +}); + +test('events belonging to another stage are never drawn', () => { + const elsewhere = setPath(fresh(), 'stage', 'penthouse'); + const { eligible, excluded } = eligibleEvents(testPack, elsewhere); + assert.equal(eligible.length, 0); + assert.ok(excluded.every((x) => x.reason === 'stage')); +}); + +test('selection is a weighted draw, so the heavy event dominates', () => { + const rng = createRng('draw'); + const state = setPath(fresh(), 'seen.first_day', 1); // take the opener out of the way + const counts = {}; + for (let i = 0; i < 1000; i++) { + const event = selectEvent(testPack, state, rng); + counts[event.id] = (counts[event.id] ?? 0) + 1; + } + assert.ok(counts.routine > 900, `routine (weight 100) should dominate audit (weight 5): ${JSON.stringify(counts)}`); + assert.ok(counts.audit > 0, 'a low weight is still reachable'); +}); + +test('an option below its skill gate resolves as locked, with the reason', () => { + const event = testPack.events.find((e) => e.id === 'routine'); + const resolved = resolveOptions(fresh(), event); + + const charm = resolved.find((r) => r.option.id === 'charm'); + assert.equal(charm.available, false); + assert.equal(charm.failed[0].path, 'skills.talking'); + assert.equal(charm.hidden, false, 'locked options are shown greyed out unless the content says otherwise'); + + assert.equal(resolved.find((r) => r.option.id === 'work').available, true); +}); + +test('an option unlocks once the gate is met', () => { + const skilled = setPath(fresh(), 'skills.talking', 5); + const event = testPack.events.find((e) => e.id === 'routine'); + assert.equal(resolveOptions(skilled, event).find((r) => r.option.id === 'charm').available, true); +}); diff --git a/test/validate.test.js b/test/validate.test.js new file mode 100644 index 0000000..03cfb88 --- /dev/null +++ b/test/validate.test.js @@ -0,0 +1,121 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { validatePack, assertValidPack } from '../src/engine/validate.js'; +import { testPack } from './fixtures/test-pack.js'; + +/** A copy of the fixture with one thing broken. */ +function broken(mutate) { + const pack = structuredClone(testPack); + mutate(pack); + return validatePack(pack); +} + +const messages = (result) => result.errors.join('\n'); + +test('the fixture pack is valid and warning-free', () => { + const result = validatePack(testPack); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.warnings, []); +}); + +test('an effect writing an undeclared path is caught', () => { + const result = broken((pack) => { + pack.events[0].options[0].effects.push({ path: 'skills.wleding', op: 'add', value: 1 }); + }); + assert.match(messages(result), /skills\.wleding.*not declared/s); +}); + +test('a relationship effect naming a character who does not exist is caught', () => { + const result = broken((pack) => { + pack.events[0].options[0].effects.push({ path: 'relationships.dave.likes_you', op: 'add', value: 1 }); + }); + assert.match(messages(result), /relationships\.dave\.likes_you.*not declared/s); +}); + +test('a condition reading an undeclared path is caught', () => { + const result = broken((pack) => { + pack.events[0].options[1].requires = [{ path: 'skills.charisma', op: '>=', value: 1 }]; + }); + assert.match(messages(result), /skills\.charisma.*not declared/s); +}); + +test('content may read engine bookkeeping but not write it', () => { + const readsTurn = broken((pack) => { + // Not the floor event — see the fallback rule below. + pack.events.find((e) => e.id === 'audit').requires = [{ path: 'turn', op: '>=', value: 3 }]; + }); + assert.deepEqual(readsTurn.errors, []); + + const writesTurn = broken((pack) => { + pack.events[0].options[0].effects.push({ path: 'turn', op: 'add', value: 5 }); + }); + assert.match(messages(writesTurn), /engine-owned state/); +}); + +test('declaring a reserved root as pack state is caught', () => { + const result = broken((pack) => { pack.state['history'] = { initial: [] }; }); + assert.match(messages(result), /reserved root/); +}); + +test('unknown operators are caught before they reach the engine', () => { + const badCondition = broken((pack) => { + pack.events[0].options[0].requires = [{ path: 'money', op: 'exceeds', value: 1 }]; + }); + assert.match(messages(badCondition), /unknown condition operator/); + + const badEffect = broken((pack) => { + pack.events[0].options[0].effects[0].op = 'multiply'; + }); + assert.match(messages(badEffect), /unknown effect operation/); +}); + +test('add with a non-numeric value is caught', () => { + const result = broken((pack) => { pack.events[0].options[0].effects[0].value = 'ten'; }); + assert.match(messages(result), /non-numeric/); +}); + +test('duplicate ids are caught', () => { + assert.match(messages(broken((pack) => { pack.events[1].id = 'routine'; })), /duplicate id/); + assert.match(messages(broken((pack) => { pack.characters[1].id = 'chief'; })), /duplicate id/); + assert.match(messages(broken((pack) => { pack.events[0].options[1].id = 'work'; })), /duplicate id/); +}); + +test('an id that cannot be a path segment is caught, because ids become state paths', () => { + const result = broken((pack) => { pack.events[0].id = 'routine day'; }); + assert.match(messages(result), /must match/); +}); + +test('an event with no options is caught', () => { + const result = broken((pack) => { pack.events[0].options = []; }); + assert.match(messages(result), /no options/); +}); + +test('a zero weight is caught, since the event could never fire', () => { + const result = broken((pack) => { pack.events[0].weight = 0; }); + assert.match(messages(result), /greater than zero/); +}); + +test('a stage with no unconditional event is caught, since the pool can run dry', () => { + const result = broken((pack) => { + pack.events[0].requires = [{ path: 'money', op: '>', value: 0 }]; + }); + assert.match(messages(result), /no unconditional event/); +}); + +test('a starting stage with no events at all is caught', () => { + const result = broken((pack) => { pack.startingStage = 'penthouse'; }); + assert.match(messages(result), /startingStage "penthouse" has no events/); +}); + +test('a character with no role_type warns but still plays', () => { + const result = broken((pack) => { delete pack.characters[0].role_type; }); + assert.deepEqual(result.errors, []); + assert.match(result.warnings.join('\n'), /role_type/); +}); + +test('assertValidPack throws with every error listed', () => { + const pack = structuredClone(testPack); + pack.events[0].options[0].effects.push({ path: 'nope', op: 'add', value: 1 }); + assert.throws(() => assertValidPack(pack), /is invalid/); + assert.doesNotThrow(() => assertValidPack(testPack)); +});