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
99 lines
3.5 KiB
JavaScript
99 lines
3.5 KiB
JavaScript
// Choosing what happens this turn.
|
|
//
|
|
// There is deliberately no such thing as "a scripted turn" versus "a random
|
|
// event" in here. Every turn, the whole event pool is filtered by stage and
|
|
// conditions, then drawn from by weight. A routine turn is an event with a
|
|
// high weight and loose conditions; a rare interruption is one with a low
|
|
// weight and tight conditions; the "hard times" branch is one whose
|
|
// conditions include a money threshold. The engine branches on none of it.
|
|
|
|
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) {
|
|
const eligible = [];
|
|
const excluded = [];
|
|
|
|
for (const event of pack.events ?? []) {
|
|
if (event.stage !== undefined && event.stage !== state.stage) {
|
|
excluded.push({ event, reason: 'stage' });
|
|
continue;
|
|
}
|
|
const seenCount = getPath(state, `seen.${event.id}`) ?? 0;
|
|
if (event.once && seenCount > 0) {
|
|
excluded.push({ event, reason: 'once' });
|
|
continue;
|
|
}
|
|
if (event.cooldown) {
|
|
const lastTurn = getPath(state, `lastSeen.${event.id}`);
|
|
if (lastTurn !== undefined && state.turn - lastTurn < event.cooldown) {
|
|
excluded.push({ event, reason: 'cooldown' });
|
|
continue;
|
|
}
|
|
}
|
|
const gate = check(state, event.requires);
|
|
if (!gate.passed) {
|
|
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);
|
|
}
|
|
|
|
return { eligible, excluded };
|
|
}
|
|
|
|
/**
|
|
* Draw this turn's event. Returns undefined when nothing is eligible, which is
|
|
* a content bug rather than a game state — the validator checks that at least
|
|
* one unconditional event exists per stage so this cannot happen in practice.
|
|
*/
|
|
export function selectEvent(pack, state, rng) {
|
|
const { eligible } = eligibleEvents(pack, state);
|
|
return rng.weighted(eligible, (event) => event.weight ?? 1);
|
|
}
|
|
|
|
/**
|
|
* Every option on an event, in authored order, marked available or locked and
|
|
* carrying the conditions that locked it. The UI greys locked options out; the
|
|
* feedback log records them, because what a player *could not* choose is the
|
|
* 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, bindConditions(option.requires, bindings));
|
|
return {
|
|
option,
|
|
available: gate.passed,
|
|
failed: gate.failed,
|
|
hidden: !gate.passed && option.whenLocked === 'hide',
|
|
};
|
|
});
|
|
}
|
|
|
|
/** Look up one option on an event by id. */
|
|
export function findOption(event, optionId) {
|
|
return (event.options ?? []).find((option) => option.id === optionId);
|
|
}
|