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