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'); });