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

203 lines
8.1 KiB
JavaScript

// The turn screen, in two phases.
//
// Choosing: the scenario and its options, with locked ones shown greyed and
// labelled with what they are waiting for — a player should be able to see the
// door they cannot open yet, because that is most of what makes the numbers
// mean anything.
//
// Outcome: what happened, what moved, and what the day cost regardless of the
// choice. The feedback widget lives here rather than in the choosing phase,
// because this is the moment a player has an opinion.
import { h } from '../dom.js';
import { statPanel, characterCard, changeList, statusBar } from '../components.js';
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
import { presentPeople } from '../../engine/cast.js';
import { fillText } from '../../engine/text.js';
export function turnScreen(pack, state, schema, view, actions) {
const heading = turnHeading(pack, state);
return h('div', { class: 'screen screen--turn' },
statusBar(pack, state),
h('header', { class: 'hud' },
h('div', { class: 'hud__where' },
h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage),
h('h1', { class: 'hud__day' }, heading.title),
heading.detail && h('p', { class: 'hud__detail' }, heading.detail),
),
statPanel(pack, state, schema),
),
diary(pack, state),
h('div', { class: 'layout' },
h('main', { class: 'scene' },
view.phase === 'outcome'
? outcomeView(pack, state, view, actions)
: choosingView(pack, state, view, actions),
),
h('aside', { class: 'people' },
h('h2', { class: 'people__heading' }, pack.peopleHeading ?? 'The people'),
presentPeople(pack, state).map((person) => characterCard(pack, state, schema, person)),
),
),
// Order here is tab order. "Wrap up my career" used to sit first, directly
// after the last option — so a player tabbing to reach an option, going one
// past it, and pressing Enter got the retrospective instead of a turn. That
// happened in a real session at day 31 and read as the game ending. It goes
// last now, behind buttons that do nothing frightening if pressed by
// accident, and Import save is next to Export save where it belongs.
h('footer', { class: 'controls' },
h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'),
h('button', { class: 'button button--quiet', onClick: actions.onImportSave }, 'Import save'),
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
'Export feedback log'),
h('button', { class: 'button button--quiet', onClick: actions.onPacks }, 'Change setting'),
h('button', { class: 'button button--quiet', onClick: actions.onNewGame }, 'New game'),
h('button', { class: 'button', onClick: actions.onRetrospective },
'Wrap up my career'),
),
);
}
/**
* What is coming and when. A player asked what the daily drain was for; the
* answer is that most of it is not daily, and this says so before the money
* moves rather than after.
*/
function diary(pack, state) {
const upcoming = upcomingUpkeep(pack, state);
if (!upcoming.length) return null;
const unit = pack.turnUnit ?? 'turn';
return h('p', { class: 'diary' },
upcoming.map((entry, index) => h('span', {
class: `diary__entry diary__entry--${entry.incoming ? 'in' : 'out'}`,
},
index > 0 ? ' · ' : '',
entry.label,
entry.turnsAway === 0
? ' today'
: entry.turnsAway === 1 ? ` tomorrow` : ` in ${entry.turnsAway} ${unit}s`,
)),
);
}
function choosingView(pack, state, view, actions) {
const { event, options } = view.turn;
const text = (t) => fillText(pack, state, t, view.turn.bindings);
const visible = options.filter((o) => !o.hidden);
// The first option a player can actually take gets the focus, so Enter plays
// it and Tab walks to the others.
const firstAvailable = visible.find((o) => o.available)?.option.id;
return h('section', { class: 'scenario' },
h('p', { class: 'scenario__text' }, text(event.description)),
h('ul', { class: 'options' },
visible.map(({ option, available, failed, odds }) => h('li', { class: 'options__item' },
h('button', {
class: `option${available ? '' : ' option--locked'}`,
disabled: !available,
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
onClick: available ? () => actions.onChoose(option.id) : null,
},
h('span', { class: 'option__label' }, text(option.label)),
// Odds are shown in words, never as a number: "a long shot", "likely".
available && odds && h('span', { class: 'option__odds' }, odds.words),
!available && h('span', { class: 'option__requirement' }, requirementText(pack, failed, state)),
),
)),
),
);
}
function outcomeView(pack, state, view, actions) {
const { record, option } = view;
// The state has already moved on to the next event, so the names in this
// outcome come from the bindings the turn was played with, not the new ones.
const text = (t) => fillText(pack, state, t, record.bindings ?? {});
const branch = record.check ? option[record.check.outcome] : null;
const result = branch ? branch.result : option.result;
const said = pack.checks?.said ?? { success: 'It came off.', failure: 'It did not come off.' };
return h('section', { class: 'outcome' },
h('p', { class: 'outcome__chose' }, record.optionLabel ?? text(option.label)),
record.check && h('p', { class: `outcome__check outcome__check--${record.check.outcome}` },
`${capitalise(record.check.odds)} — ${said[record.check.outcome]}`),
result && h('p', { class: 'outcome__text' }, text(result)),
h('div', { class: 'outcome__ledger' },
h('h3', { class: 'outcome__heading' }, 'What that changed'),
changeList(pack, record.changes, { state }),
record.upkeep.length > 0 && h('div', { class: 'outcome__upkeep' },
h('h3', { class: 'outcome__heading' }, 'And then the day happened'),
changeList(pack, record.upkeep, { state }),
),
),
feedbackWidget(view, actions),
h('button', {
class: 'button button--primary button--next',
dataset: { autofocus: 'true' },
onClick: actions.onNext,
}, `Next ${pack.turnUnit ?? 'turn'} →`),
);
}
// Entirely optional: ignoring it does nothing at all, which is the point.
function feedbackWidget(view, actions) {
const { feedback } = view;
const ratingButton = (value, label) => h('button', {
class: `rating${feedback.rating === value ? ' rating--on' : ''}`,
type: 'button',
'aria-pressed': String(feedback.rating === value),
onClick: () => actions.onRate(value),
}, label);
return h('section', { class: 'feedback' },
h('div', { class: 'feedback__row' },
h('span', { class: 'feedback__prompt' }, 'How was that?'),
h('div', { class: 'feedback__ratings' },
ratingButton('up', '👍'),
ratingButton('neutral', '😐'),
ratingButton('down', '👎'),
),
h('button', {
class: 'feedback__toggle',
type: 'button',
onClick: actions.onToggleFeedbackDetail,
}, view.feedbackOpen ? 'Fewer options' : 'Say more'),
),
view.feedbackOpen && h('div', { class: 'feedback__detail' },
h('label', { class: 'field' },
h('span', { class: 'field__label' }, 'Anything you want to tell the designer?'),
h('textarea', {
class: 'field__input',
rows: 2,
value: feedback.comment ?? '',
onInput: (e) => actions.onComment(e.target.value),
}),
),
h('label', { class: 'field' },
h('span', { class: 'field__label' },
"Something you wanted to do that wasn't offered?"),
h('textarea', {
class: 'field__input',
rows: 2,
value: feedback.desiredAction ?? '',
onInput: (e) => actions.onDesiredAction(e.target.value),
}),
),
),
feedback.saved && h('p', { class: 'feedback__saved' }, 'Noted — thank you.'),
);
}
const capitalise = (words) => words.charAt(0).toUpperCase() + words.slice(1);