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
162 lines
6.4 KiB
JavaScript
162 lines
6.4 KiB
JavaScript
// Turning state paths and conditions into English.
|
||
//
|
||
// Display names, number formats and which stats appear in the header all come
|
||
// from the pack's `display` map, so a new setting renames and re-orders the
|
||
// interface without touching the UI. Anything the pack does not describe falls
|
||
// back to a derivation from the path itself, so content is never *required* to
|
||
// supply a label just to be playable.
|
||
|
||
import { getPath } from '../engine/paths.js';
|
||
import { check } from '../engine/conditions.js';
|
||
import { personIn } from '../engine/cast.js';
|
||
|
||
const titleCase = (text) =>
|
||
text.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
||
|
||
/** The pack's display rules for a path, if it has any. */
|
||
export function displayFor(pack, path) {
|
||
return pack.display?.[path] ?? null;
|
||
}
|
||
|
||
/**
|
||
* A human name for a path, derived from the path when the pack is silent.
|
||
* Given the run's state, a relationship path names whoever is in that slot this
|
||
* run — a drawn boss, the second of three co-workers — not the role.
|
||
*/
|
||
export function labelFor(pack, path, state) {
|
||
const declared = displayFor(pack, path)?.label;
|
||
if (declared) return declared;
|
||
|
||
const segments = path.split('.');
|
||
if (segments[0] === 'relationships' && segments.length === 3) {
|
||
const name = (state && personIn(pack, state, segments[1])?.name)
|
||
?? pack.characters?.find((c) => c.id === segments[1])?.name;
|
||
const stat = pack.relationshipStats?.[segments[2]]?.label ?? titleCase(segments[2]);
|
||
return `${name ?? titleCase(segments[1])} — ${stat.toLowerCase()}`;
|
||
}
|
||
return titleCase(segments.at(-1));
|
||
}
|
||
|
||
/** Money is written as money; everything else is a plain number. */
|
||
export function formatValue(pack, path, value) {
|
||
if (typeof value === 'boolean') return value ? 'yes' : 'no';
|
||
const rule = displayFor(pack, path);
|
||
if (rule?.format === 'money') return formatMoney(value);
|
||
// A tier is a number the player should never see as one: a pack declares
|
||
// what each step is called and the interface reads the name off the list.
|
||
if (rule?.format === 'tier') return rule.labels?.[value] ?? String(value);
|
||
return String(value);
|
||
}
|
||
|
||
export function formatMoney(amount) {
|
||
const rounded = Math.round(amount);
|
||
const body = `$${Math.abs(rounded).toLocaleString('en-US')}`;
|
||
return rounded < 0 ? `−${body}` : body;
|
||
}
|
||
|
||
/** A signed change, for the outcome list: "+$45", "−3". */
|
||
export function formatDelta(pack, path, from, to) {
|
||
const delta = to - from;
|
||
if (displayFor(pack, path)?.format === 'money') {
|
||
return `${delta > 0 ? '+' : '−'}$${Math.abs(Math.round(delta)).toLocaleString('en-US')}`;
|
||
}
|
||
return `${delta > 0 ? '+' : '−'}${Math.abs(delta)}`;
|
||
}
|
||
|
||
/**
|
||
* One condition as a requirement the player can read: "Negotiation 15+",
|
||
* "Trevor — likes you 48+", "not in debt".
|
||
*/
|
||
export function describeCondition(pack, condition, state) {
|
||
if (!condition || typeof condition !== 'object') return '';
|
||
if (Array.isArray(condition)) return condition.map((c) => describeCondition(pack, c, state)).join(', ');
|
||
if (condition.all) return condition.all.map((c) => describeCondition(pack, c, state)).join(', ');
|
||
if (condition.any) return condition.any.map((c) => describeCondition(pack, c, state)).join(' or ');
|
||
if (condition.not) return `not ${describeCondition(pack, condition.not, state)}`;
|
||
|
||
const { path, op, value } = condition;
|
||
const label = labelFor(pack, path, state);
|
||
|
||
if (typeof value === 'boolean') return value ? label.toLowerCase() : `no ${label.toLowerCase()}`;
|
||
|
||
const amount = formatValue(pack, path, value);
|
||
switch (op) {
|
||
case '>=': return `${label} ${amount}+`;
|
||
case '>': return `${label} over ${amount}`;
|
||
case '<=': return `${label} ${amount} or less`;
|
||
case '<': return `${label} under ${amount}`;
|
||
case '==': return `${label} exactly ${amount}`;
|
||
case '!=': return `${label} other than ${amount}`;
|
||
default: return `${label} ${op} ${amount}`;
|
||
}
|
||
}
|
||
|
||
/** What a locked option is waiting for. */
|
||
export function requirementText(pack, failedConditions, state) {
|
||
const parts = failedConditions.map((c) => describeCondition(pack, c, state)).filter(Boolean);
|
||
return parts.length ? `Needs ${parts.join(', ')}` : 'Not available yet';
|
||
}
|
||
|
||
/**
|
||
* The stats the header shows, in the pack's declared order, skipping anything
|
||
* the pack marks hidden or asks to hide while it is zero.
|
||
*/
|
||
export function headerStats(pack, state, schema) {
|
||
return Object.entries(pack.display ?? {})
|
||
.filter(([, rule]) => !rule.hidden)
|
||
.map(([path, rule]) => ({
|
||
path,
|
||
rule,
|
||
label: labelFor(pack, path),
|
||
value: getPath(state, path),
|
||
bounds: schema[path],
|
||
}))
|
||
.filter((stat) => !(stat.rule.hideWhenZero && !stat.value))
|
||
.sort((a, b) => (a.rule.order ?? 99) - (b.rule.order ?? 99));
|
||
}
|
||
|
||
/**
|
||
* How the current turn is titled. A pack with a calendar gets its day name and
|
||
* cycle ("Thursday", "Week 2 · Day 12"); one without falls back to the turn
|
||
* number alone, which is what the engine guarantees.
|
||
*/
|
||
export function turnHeading(pack, state) {
|
||
const unit = capitalize(pack.turnUnit ?? 'turn');
|
||
const name = state.calendar?.name;
|
||
if (!name) return { title: `${unit} ${state.turn}`, detail: null };
|
||
|
||
const cycleName = pack.calendar?.cycleName ?? 'Cycle';
|
||
return {
|
||
title: name,
|
||
detail: `${cycleName} ${state.calendar.cycle} · ${unit} ${state.turn}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* The pack's scheduled upkeep, as "what is coming and when". Answers the
|
||
* question a player actually has — when am I next paid, when is rent due —
|
||
* without them having to reverse-engineer it from the ledger.
|
||
*/
|
||
export function upcomingUpkeep(pack, state, { limit = 3 } = {}) {
|
||
const upcoming = [];
|
||
for (const entry of pack.upkeep ?? []) {
|
||
if (!entry.every || !entry.label) continue;
|
||
if (!check(state, entry.requires).passed) continue;
|
||
|
||
const offset = entry.offset ?? 0;
|
||
const elapsed = state.turn - 1 - offset;
|
||
const turnsAway = elapsed < 0 ? -elapsed : (entry.every - (elapsed % entry.every)) % entry.every;
|
||
const amount = (entry.effects ?? [])
|
||
.filter((e) => e.op === 'add' && typeof e.value === 'number')
|
||
.reduce((sum, e) => sum + e.value, 0);
|
||
|
||
upcoming.push({ label: entry.label, turnsAway, incoming: amount > 0 });
|
||
}
|
||
return upcoming
|
||
.sort((a, b) => a.turnsAway - b.turnsAway)
|
||
.filter((entry, index, all) => all.findIndex((e) => e.label === entry.label) === index)
|
||
.slice(0, limit);
|
||
}
|
||
|
||
const capitalize = (text) => text.charAt(0).toUpperCase() + text.slice(1);
|