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
+22 -1
View File
@@ -9,6 +9,21 @@
import { check } from './conditions.js';
import { getPath } from './paths.js';
import { presentSlots } from './cast.js';
import { bindConditions } from './text.js';
/**
* Pick, for each role an event `involves`, one person present in this run. An
* event about "a co-worker" is about one particular co-worker for the turn.
*/
export function bindRoles(pack, state, event, rng) {
const bindings = {};
for (const role of event.involves ?? []) {
const slots = presentSlots(pack, state, role);
if (slots.length) bindings[role] = slots.length === 1 ? slots[0] : rng.pick(slots);
}
return bindings;
}
/** Events that could fire right now, with the reason excluded ones could not. */
export function eligibleEvents(pack, state) {
@@ -37,6 +52,11 @@ export function eligibleEvents(pack, state) {
excluded.push({ event, reason: 'requires', failed: gate.failed });
continue;
}
// An event about a co-worker cannot fire in a run that drew none.
if ((event.involves ?? []).some((role) => !presentSlots(pack, state, role).length)) {
excluded.push({ event, reason: 'involves' });
continue;
}
eligible.push(event);
}
@@ -60,8 +80,9 @@ export function selectEvent(pack, state, rng) {
* strongest signal that the choice set is too narrow.
*/
export function resolveOptions(state, event) {
const bindings = state?.meta?.bindings ?? {};
return (event.options ?? []).map((option) => {
const gate = check(state, option.requires);
const gate = check(state, bindConditions(option.requires, bindings));
return {
option,
available: gate.passed,