// The cast drawn per run, names in text, and options whose outcome is a check. // See DECISIONS #41. Runs against test/fixtures/cast-pack.js, never real content. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { startGame, currentTurn, takeTurn } from '../src/engine/game.js'; import { validatePack } from '../src/engine/validate.js'; import { hydrate, createState, resolveSchema } from '../src/engine/state.js'; import { presentPeople, presentSlots, personIn } from '../src/engine/cast.js'; import { fillText } from '../src/engine/text.js'; import { chanceOf, oddsWords } from '../src/engine/checks.js'; import { getPath, setPath } from '../src/engine/paths.js'; import { castPack } from './fixtures/cast-pack.js'; import { testPack } from './fixtures/test-pack.js'; const clone = (value) => structuredClone(value); /** Advance until the current event is `eventId`, taking `work` otherwise. */ function until(state, eventId, pack = castPack) { let next = state; for (let i = 0; i < 200 && currentTurn(pack, next).event.id !== eventId; i++) { next = takeTurn(pack, next, currentTurn(pack, next).options[0].option.id).state; } assert.equal(currentTurn(pack, next).event.id, eventId); return next; } test('the cast pack is valid, with no warnings', () => { const { errors, warnings } = validatePack(castPack); assert.deepEqual(errors, []); assert.deepEqual(warnings, []); }); test('a run draws one boss and two to four different co-workers', () => { const counts = new Set(); for (let seed = 0; seed < 40; seed++) { const state = startGame(castPack, { seed }); const people = presentPeople(castPack, state); assert.equal(people.filter((p) => p.role === 'boss').length, 1); const coworkers = presentSlots(castPack, state, 'coworker'); assert.ok(coworkers.length >= 2 && coworkers.length <= 4, `drew ${coworkers.length}`); counts.add(coworkers.length); const names = people.map((p) => p.name); assert.equal(new Set(names).size, names.length, 'nobody is drawn twice'); assert.ok(names.includes('Old Sam'), 'a fixed character is always present'); } assert.deepEqual([...counts].sort(), [2, 3, 4], 'every head count turns up across seeds'); }); test('the cast is the seed\'s: the same seed draws the same people, others differ', () => { const names = (seed) => presentPeople(castPack, startGame(castPack, { seed })).map((p) => p.name).join('|'); assert.equal(names('a'), names('a')); const distinct = new Set(Array.from({ length: 20 }, (_, i) => names(`s${i}`))); assert.ok(distinct.size > 10, `only ${distinct.size} different casts in 20 runs`); }); test('an empty group slot is not in the run, and a drawn person brings their own starting regard', () => { let seed = 0; let state; do state = startGame(castPack, { seed: seed++ }); while (presentSlots(castPack, state, 'coworker').length !== 2 || personIn(castPack, state, 'boss').name !== 'Walter Grose'); assert.equal(getPath(state, 'cast.coworker_3'), null); assert.equal(personIn(castPack, state, 'coworker_3'), undefined); assert.equal(getPath(state, 'relationships.boss.trust'), 30); }); test('names are filled into text from whoever was drawn', () => { const state = startGame(castPack, { seed: 'names' }); const boss = personIn(castPack, state, 'boss'); assert.equal(fillText(castPack, state, 'Ask {boss.first}, the {boss.title}.'), `Ask ${boss.name.split(' ')[0]}, the Manager.`); assert.equal(fillText(castPack, state, '{porter} nods.'), 'Old Sam nods.'); assert.equal(fillText(castPack, state, '{nobody} stays as written.'), '{nobody} stays as written.'); }); test('an event about a co-worker binds one who is present, and its effects land on them', () => { const state = until(startGame(castPack, { seed: 'bind' }), 'needled'); const turn = currentTurn(castPack, state); const slot = turn.bindings.coworker; assert.ok(presentSlots(castPack, state, 'coworker').includes(slot)); assert.match(fillText(castPack, state, turn.event.description), new RegExp(personIn(castPack, state, slot).name)); const before = getPath(state, `relationships.${slot}.regard`); const { state: after, record } = takeTurn(castPack, state, 'ignore'); assert.equal(getPath(after, `relationships.${slot}.regard`), before - 2); assert.deepEqual(record.bindings, { coworker: slot }); }); test('the chance follows the formula, and the odds are words', () => { let state = startGame(castPack, { seed: 'odds' }); const option = castPack.events[0].options[2]; // wits against difficulty 5 state = setPath(state, 'attributes.wits', 5); assert.equal(chanceOf(castPack, state, option).chance, 50); state = setPath(state, 'attributes.wits', 7); assert.equal(chanceOf(castPack, state, option).chance, 70); state = setPath(state, 'attributes.wits', 10); assert.equal(chanceOf(castPack, state, option).chance, 100, 'clamped at the ceiling'); assert.equal(oddsWords(castPack, 15), 'a long shot'); assert.equal(oddsWords(castPack, 50), 'an even chance'); assert.equal(oddsWords(castPack, 90), 'a safe bet'); }); test('an opposed check reads the other person\'s attribute, and their regard moves it', () => { let state = startGame(castPack, { seed: 'opposed' }); const option = castPack.events[0].options[1]; // charm against the boss's wits, leaning on trust const boss = personIn(castPack, state, 'boss'); state = setPath(state, 'attributes.charm', 6); state = setPath(state, 'relationships.boss.trust', 50); const even = chanceOf(castPack, state, option); assert.equal(even.theirs, boss.attributes.wits); assert.equal(even.chance, Math.min(100, Math.max(0, 50 + 10 * (6 - boss.attributes.wits)))); state = setPath(state, 'relationships.boss.trust', 70); assert.equal(chanceOf(castPack, state, option).chance, Math.min(100, even.chance + 10)); }); test('a check applies the branch it rolled, plus the option\'s own effects', () => { let state = until(startGame(castPack, { seed: 'roll' }), 'routine'); const sure = setPath(setPath(state, 'attributes.charm', 10), 'relationships.boss.trust', 100); const won = takeTurn(castPack, sure, 'ask_for_more'); assert.equal(won.record.check.outcome, 'success'); assert.equal(getPath(won.state, 'money'), getPath(sure, 'money') + 50); assert.equal(getPath(won.state, 'relationships.boss.regard'), getPath(sure, 'relationships.boss.regard') - 1); const hopeless = setPath(setPath(state, 'attributes.charm', 0), 'relationships.boss.trust', 0); const lost = takeTurn(castPack, hopeless, 'ask_for_more'); assert.equal(lost.record.check.outcome, 'failure'); assert.equal(getPath(lost.state, 'money'), getPath(hopeless, 'money')); assert.equal(getPath(lost.state, 'relationships.boss.trust'), 0, 'clamped'); assert.match(lost.record.check.odds, /long shot/); }); test('a run with checks and bindings replays identically from its seed and choices', () => { const play = () => { let state = startGame(castPack, { seed: 'replay' }); const records = []; for (let i = 0; i < 60; i++) { const turn = currentTurn(castPack, state); const pick = turn.options[i % turn.options.length].option.id; const result = takeTurn(castPack, state, pick); records.push(result.record); state = i === 30 ? JSON.parse(JSON.stringify(result.state)) : result.state; } return { state, records }; }; const a = play(); const b = play(); assert.deepEqual(a.records.map((r) => r.check?.outcome ?? '-'), b.records.map((r) => r.check?.outcome ?? '-')); assert.deepEqual(a.state, b.state); assert.ok(a.records.some((r) => r.check?.outcome === 'success') && a.records.some((r) => r.check?.outcome === 'failure')); }); test('a save from before the pack drew a cast gets one on load, the same one every time', () => { const schema = resolveSchema(castPack); const fresh = createState(castPack, { seed: 'old', schema }); const old = clone(fresh); delete old.cast; const once = hydrate(castPack, clone(old), schema); const twice = hydrate(castPack, clone(old), schema); assert.ok(once.added.includes('cast')); assert.deepEqual(once.state.cast, twice.state.cast); assert.deepEqual(once.state.cast, fresh.cast); }); test('a pack with no drawn cast gets no cast and no bindings: its runs are unchanged', () => { const state = startGame(testPack, { seed: 'plain' }); assert.equal(state.cast, undefined); assert.equal(state.meta.bindings, undefined); assert.equal(presentPeople(testPack, state).map((p) => p.name).join(), 'The Chief,Pat'); }); test('the validator catches cast and check mistakes', () => { const broken = clone(castPack); broken.events[0].options[1].check.attribute = 'strength'; broken.events[0].options[2].failure = undefined; broken.events[0].description = 'Blame {coworker}.'; broken.events.push({ id: 'ghost', stage: 'desk', description: '{ghost} appears.', options: [{ id: 'x', label: 'x', effects: [] }] }); broken.characters[1].count = [2, 6]; const { errors } = validatePack(broken); const has = (fragment) => assert.ok(errors.some((e) => e.includes(fragment)), `expected an error mentioning "${fragment}":\n${errors.join('\n')}`); has('attribute "strength" is not in pack.attributes'); has('has no failure'); has('{coworker} is a group'); has('{ghost} names no character'); has('has only 5 candidates'); });