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,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');
|
||||
});
|
||||
Reference in New Issue
Block a user