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,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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.<id>.<stat>, 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.<eventId>`,
|
||||||
|
`lastSeen.<eventId>`) 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.
|
||||||
@@ -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.<eventId>` is a count and `lastSeen.<eventId>` 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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Vendored
+112
@@ -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;
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user