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
203 lines
8.1 KiB
JavaScript
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);
|