Files
TheLadder/src/engine/select.js
T
JesseMarkowitzandClaude Opus 5.5 3108319a2b 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
2026-09-23 22:12:26 -04:00

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