An option used to do exactly what it said. Now it may carry a check: one of
four attributes (Nerve, Wits, Charm, Resolve) against a set difficulty or
against a named person's own attribute, leaning on how they regard you, with a
success and a failure. The player sees only the odds in words ("a long shot",
"likely"), never a number. The roll is one draw from the run's generator, so a
run still replays exactly from its seed and choices.
A character may be a role with several authored candidates, one drawn per run,
or a group of two to four drawn into numbered slots. Everyone carries a name, a
description and the same four attributes as the player, so a replayed pack
meets a different office. Text names people as {partner} or {clerk}, and an
event about "a clerk" binds one present clerk for the turn. The draw and the
binding are saved with the run, and a save from before a pack drew a cast gets
the same one every time it is loaded.
All of it is generic and optional: Corporate Ladder and the Frontier use none
of it and play as before. The Counting House, a one-stage office about 1910
built from the story-to-pack catalogue, exercises it, and was tuned against the
conformance suite by measurement. DECISIONS #41. Version 0.4.0.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018MhABSrqkwTdcybXmheDPr
186 lines
9.2 KiB
JavaScript
186 lines
9.2 KiB
JavaScript
// 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');
|
|
});
|