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,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