Files
TheLadder/src/ui/format.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

162 lines
6.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);