Add chance and a cast that changes between runs, and a pack to try them

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
This commit is contained in:
JesseMarkowitz
2026-09-23 22:12:26 -04:00
co-authored by Claude Opus 5.5
parent 112e558dbb
commit 3108319a2b
25 changed files with 2257 additions and 77 deletions
+185
View File
@@ -0,0 +1,185 @@
// 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');
});
+7 -1
View File
@@ -144,7 +144,12 @@ for (const pack of packs) {
const overcharging = [];
for (const event of pack.events) {
for (const option of event.options ?? []) {
const effects = option.effects ?? [];
// A check's branches pay and spend too (DECISIONS #41): each is judged
// together with the effects that apply either way.
const branches = option.check
? [option.success, option.failure].map((b) => [...(option.effects ?? []), ...(b?.effects ?? [])])
: [option.effects ?? []];
for (const effects of branches) {
const spends = effects.some((e) => e.path === 'money' && e.op === 'add'
&& (typeof e.value === 'number' ? e.value < 0 : (e.value?.times ?? 1) < 0));
if (!spends) continue;
@@ -155,6 +160,7 @@ for (const pack of packs) {
.some((r) => r.path === e.path && r.op === '>=' && r.value >= -e.value);
if (!covered) overcharging.push(`${event.id}.${option.id} (${e.path} ${e.value})`);
}
}
}
}
assert.ok(balances.length > 0 || pack.display === undefined, 'a pack with a balance should declare it as money');
+114
View File
@@ -0,0 +1,114 @@
// A small pack for the cast and checks (DECISIONS #41): a drawn single role, a
// group role of two to four, a fixed character with attributes, opposed and
// unopposed checks, and an event about "one of the co-workers". Its checks run
// from 0 to 100 so tests can force an outcome; a real pack keeps a floor and a
// ceiling so nothing is ever certain.
export const castPack = {
id: 'castpack',
name: 'Cast Pack',
version: '1.0.0',
turnUnit: 'day',
startingStage: 'desk',
attributes: {
nerve: { label: 'Nerve' },
wits: { label: 'Wits' },
charm: { label: 'Charm' },
resolve: { label: 'Resolve' },
},
checks: {
base: 50, perPoint: 10, unopposed: 5,
relationshipPerPoint: 0.5, relationshipCentre: 50,
floor: 0, ceiling: 100,
},
state: {
'money': { initial: 100, min: -500, max: 10000 },
'attributes.nerve': { initial: 5, min: 0, max: 10 },
'attributes.wits': { initial: 5, min: 0, max: 10 },
'attributes.charm': { initial: 5, min: 0, max: 10 },
'attributes.resolve': { initial: 5, min: 0, max: 10 },
},
relationshipStats: {
trust: { label: 'Trusts you', initial: 50, min: 0, max: 100 },
regard: { label: 'Thinks of you', initial: 50, min: 0, max: 100 },
},
characters: [
{
id: 'boss',
role_type: 'boss',
title: 'Manager',
candidates: [
{ name: 'Agnes Pell', description: 'Reads every memo twice.', attributes: { nerve: 6, wits: 8, charm: 3, resolve: 7 } },
{ name: 'Walter Grose', description: 'Loud, and quicker than he looks.', attributes: { nerve: 7, wits: 6, charm: 6, resolve: 4 },
startingRelationship: { trust: 30 } },
{ name: 'Mina Hart', description: 'Has never raised her voice.', attributes: { nerve: 4, wits: 7, charm: 8, resolve: 6 } },
],
},
{
id: 'coworker',
role_type: 'peer',
title: 'Clerk',
count: [2, 4],
candidates: [
{ name: 'Ben Aske', description: 'Owes {boss} a favour.', attributes: { nerve: 3, wits: 4, charm: 6, resolve: 5 } },
{ name: 'Cora Dunn', description: 'First in, last out.', attributes: { nerve: 5, wits: 5, charm: 4, resolve: 8 } },
{ name: 'Del Fane', description: 'Knows everyone\'s business.', attributes: { nerve: 4, wits: 7, charm: 5, resolve: 3 } },
{ name: 'Edie Gow', description: 'Quiet until she is not.', attributes: { nerve: 7, wits: 5, charm: 3, resolve: 6 } },
{ name: 'Fitz Hale', description: 'Charming, unreliable.', attributes: { nerve: 5, wits: 3, charm: 8, resolve: 2 } },
],
},
{ id: 'porter', name: 'Old Sam', role_type: 'staff', description: 'Has seen everyone come and go.',
attributes: { nerve: 2, wits: 9, charm: 4, resolve: 9 } },
],
events: [
{
id: 'routine',
stage: 'desk',
weight: 100,
description: 'A day at the desk under {boss}.',
options: [
{ id: 'work', label: 'Work.', effects: [{ path: 'money', op: 'add', value: 10 }] },
{
id: 'ask_for_more',
label: 'Ask {boss.first} for more money.',
check: { attribute: 'charm', against: 'boss', opposedBy: 'wits', relationship: 'trust' },
effects: [{ path: 'relationships.boss.regard', op: 'add', value: -1 }],
success: { result: '{boss} agrees.', effects: [{ path: 'money', op: 'add', value: 50 }] },
failure: { result: '{boss} laughs.', effects: [{ path: 'relationships.boss.trust', op: 'add', value: -5 }] },
},
{
id: 'hard_sum',
label: 'Try the hard sum.',
check: { attribute: 'wits', difficulty: 5 },
success: { result: 'It adds up.', effects: [{ path: 'attributes.wits', op: 'add', value: 1 }] },
failure: { result: 'It does not.', effects: [] },
},
],
},
{
id: 'needled',
stage: 'desk',
weight: 100,
involves: ['coworker'],
description: '{coworker} makes a remark about your tie.',
options: [
{ id: 'ignore', label: 'Let it go.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: -2 }] },
{
id: 'retort',
label: 'Answer {coworker.first} back.',
check: { attribute: 'nerve', against: '{coworker}', relationship: 'regard' },
success: { result: '{coworker} laughs, and means it.', effects: [{ path: 'relationships.{coworker}.regard', op: 'add', value: 5 }] },
failure: { result: 'It lands badly.', effects: [{ path: 'relationships.{coworker}.trust', op: 'add', value: -5 }] },
},
],
},
],
};
export default castPack;
+24 -7
View File
@@ -24,13 +24,30 @@ const amountOf = (effect) => {
return 0;
};
const moneyGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path === 'money' && e.op === 'add')
.reduce((sum, e) => sum + amountOf(e), 0);
/**
* Every effect an option can have, with the weight a player reading the odds
* would give it: plain effects count in full, a check's success and failure by
* their chance (DECISIONS #41). An option with no check is just its effects.
*/
export const weightedEffects = (resolved) => {
const { option, odds } = resolved;
const plain = (option.effects ?? []).map((effect) => ({ effect, weight: 1 }));
if (!option.check) return plain;
const p = (odds?.chance ?? 50) / 100;
return [
...plain,
...(option.success?.effects ?? []).map((effect) => ({ effect, weight: p })),
...(option.failure?.effects ?? []).map((effect) => ({ effect, weight: 1 - p })),
];
};
const relationshipGain = (resolved) => (resolved.option.effects ?? [])
.filter((e) => e.path.startsWith('relationships.'))
.reduce((sum, e) => sum + amountOf(e), 0);
const moneyGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path === 'money' && effect.op === 'add')
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const relationshipGain = (resolved) => weightedEffects(resolved)
.filter(({ effect }) => effect.path.startsWith('relationships.'))
.reduce((sum, { effect, weight }) => sum + amountOf(effect) * weight, 0);
const best = (options, score) => [...options].sort((a, b) => score(b) - score(a))[0];
@@ -59,7 +76,7 @@ export const strategies = {
*/
lifer: (options) => {
const stays = options.filter(
(o) => !(o.option.effects ?? []).some((e) => e.path === 'stage'),
(o) => !weightedEffects(o).some(({ effect }) => effect.path === 'stage'),
);
return best(stays.length ? stays : options, relationshipGain);
},
+49
View File
@@ -527,3 +527,52 @@ test('the header reads the second pack\'s display map, not the first\'s', async
dom.restore();
}
});
// ---- a drawn cast, and checks (DECISIONS #41) ----------------------------------
test('a pack with a drawn cast shows whoever this run drew, and never a placeholder', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
const people = byClass(dom.root, 'person');
// One partner, one chief clerk, and two to four clerks.
assert.ok(people.length >= 4 && people.length <= 6, `${people.length} people in the panel`);
const titles = byClass(dom.root, 'person__title').map((t) => t.textContent);
assert.equal(titles.filter((t) => t === 'Senior Partner').length, 1);
assert.equal(titles.filter((t) => t === 'Chief Clerk').length, 1);
const clerks = titles.filter((t) => t === 'Clerk').length;
assert.ok(clerks >= 2 && clerks <= 4, `${clerks} clerks`);
for (let turn = 0; turn < 25; turn++) {
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the screen');
enabledOptions(dom.root)[0].click();
assert.doesNotMatch(text(dom.root), /\{[a-z_0-9.]+\}/, 'a {placeholder} reached the outcome');
firstByClass(dom.root, 'button--next').click();
}
} finally {
dom.restore();
}
});
test('a checked option shows its odds in words, and the outcome says whether it came off', async () => {
const dom = await boot({ pack: 'countinghouse' });
try {
let checked;
for (let turn = 0; turn < 40 && !checked; turn++) {
checked = optionButtons(dom.root).find((b) => !b.disabled && firstByClass(b, 'option__odds'));
if (checked) break;
enabledOptions(dom.root)[0].click();
firstByClass(dom.root, 'button--next').click();
}
assert.ok(checked, 'a checked option turned up within forty days');
const odds = firstByClass(checked, 'option__odds').textContent;
assert.match(odds, /^(a long shot|unlikely|an even chance|likely|a safe bet)$/);
assert.doesNotMatch(text(dom.root), /\d+%/, 'no percentage is ever shown');
checked.click();
const said = text(firstByClass(dom.root, 'outcome__check'));
assert.match(said, /and it (came|did not come) off\./);
assert.ok(said.toLowerCase().startsWith(odds), `the outcome repeats the odds: "${said}"`);
assert.ok(firstByClass(dom.root, 'outcome__text'), 'and says what happened');
} finally {
dom.restore();
}
});