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
+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);
},