diff --git a/.gitignore b/.gitignore index dd6e803..693652e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,9 @@ node_modules/ dist/ + +# Exported playtest logs are somebody's real session, including whatever +# they typed into the feedback box. Kept local; see logs/README.md. +logs/* +!logs/README.md *.log .DS_Store diff --git a/README.md b/README.md index 7db737e..827e105 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,8 @@ currently playable only from a test harness. | Calendar, scheduled wages and rent | done | | Promotion from the mailroom to Dispatch | done | | Repetition analytics (`npm run analyze`) | done | +| Version and build shown on screen | done | +| Keyboard play (Enter / Tab) | done | | Played in a real browser | done — first playtest 2026-09-09 | The first playtest confirmed the interface, saves and the exported log all work @@ -49,6 +51,11 @@ it.** npm run serve # then open http://localhost:8080 ``` +The game plays from the keyboard: Enter takes the focused option, Enter again +moves to the next day, and Tab / Shift+Tab pick a different option. The top of +every screen names the game, the pack, your current title, and the version and +commit you are running. + A server is needed during development because browsers refuse ES module imports over `file://`. If port 8080 is taken, run `python3 -m http.server ` instead. @@ -63,7 +70,7 @@ That is one self-contained file with the stylesheet and every module inlined. It plays by double-clicking it — no server, no network, nothing installed. ```sh -npm test # 114 tests: engine, content, UI wiring, and the build +npm test # 119 tests: engine, content, UI wiring, and the build ``` To see what a playtest actually did — turns against distinct situations, how @@ -71,9 +78,13 @@ often each recurred, which locked gates players kept meeting, and anything they typed into the feedback box: ```sh -npm run analyze -- theladder-feedback-2026-09-10-11-04-22.json +npm run analyze # the newest export in logs/ +npm run analyze -- # a specific one ``` +Drop exports into `logs/`. They are gitignored — a log is a record of what a +real person did, including anything they typed into the feedback box. + There are no runtime dependencies and no build step for development. Node is used only to run the tests, the single-file build, and the log analyser. diff --git a/content/corporateladder/index.js b/content/corporateladder/index.js index ef46b32..b7506fe 100644 --- a/content/corporateladder/index.js +++ b/content/corporateladder/index.js @@ -116,12 +116,36 @@ export const corporateLadder = { 'reputation': { label: 'Standing', format: 'meter', order: 3 }, 'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 }, 'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 }, - 'notable': { hidden: true }, - 'flags.took_loan': { label: 'a loan on the books', hidden: true }, - 'flags.holds_the_envelope': { label: 'the envelope', hidden: true }, - 'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true }, - 'flags.promoted': { label: 'a promotion', hidden: true }, - 'flags.recommended_trevor': { label: "Trevor's recommendation", hidden: true }, + // Kept out of the header, and out of the ledger too: `notable` is the + // retrospective's own list, and "Notable — changed" tells nobody anything. + 'notable': { hidden: true, inLedger: false }, + // Flags do belong in the ledger — a flag flipping is a fact about the + // world — but as a sentence rather than as "now true". + 'flags.took_loan': { + label: 'The loan', hidden: true, + whenTrue: 'You are carrying a loan now.', + whenFalse: 'The loan is settled.', + }, + 'flags.holds_the_envelope': { + label: 'The envelope', hidden: true, + whenTrue: 'The envelope is in your locker.', + whenFalse: 'The envelope is back in the system.', + }, + 'flags.covered_for_trevor': { + label: "Trevor's Thursday", hidden: true, + whenTrue: 'Trevor owes you a Thursday.', + whenFalse: 'You and Trevor are square.', + }, + 'flags.promoted': { + label: 'Promotion', hidden: true, + whenTrue: 'You are out of the mailroom.', + whenFalse: 'Back in the mailroom.', + }, + 'flags.recommended_trevor': { + label: "Trevor's recommendation", hidden: true, + whenTrue: "Trevor's name is in for the mailroom.", + whenFalse: "Trevor's name is not in for the mailroom.", + }, }, characters, diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 01f40a7..7cf39c8 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -313,3 +313,55 @@ eight events against the mailroom's nineteen meant its two floor events were 46% of every promoted run, one every three turns. Promotion was moving the player into *thinner* content. Three more events and a weight rebalance took the top two to 24.5%, and the heaviest recurrence from every ~3 turns to every ~5. + +## 25. The screen says which build it is + +Playtest: *"need entry on the main screen showing what version we're running… +so it's easy to tell when new stuff is."* + +A status line at the top of every screen names the game, the pack, the player's +current title, and the version plus the commit it was built from. `tools/stamp.js` +writes the commit into `src/build-info.js` before serving and before building, +so a distributed `dist/theladder.html` states exactly which commit produced it, +and a dev session says so too. A working tree with uncommitted changes gets a +`+` suffix — "4cab9fc+" is honest in a way that a bare SHA would not be. + +The file is tracked rather than generated-and-ignored so a fresh clone runs +without a build step first. It changes at most once per commit, and the stamper +ignores a same-commit restamp so a running server does not churn the tree. + +## 26. The whole game is playable from the keyboard + +Playtest: *"when I get the results, the focus should automatically be on the +next day, so I can just hit Enter."* + +Every screen marks one control with `dataset.autofocus` and the app focuses it +after each render: the first *available* option while choosing, the next-turn +button once the result is in, "keep going" on the retrospective. A run is +therefore playable on Enter alone, with Tab and Shift+Tab to reach a different +option. + +Locked options need no special handling — the browser's own tab order skips +disabled controls, so they stay visible without being in the way, which is +exactly the behaviour wanted. + +## 27. The ledger is written for a player, not for the engine + +Two fixes from the same playtest note, *"what is notable? under what changed?"*. + +`notable` is the retrospective's own list of moments. Reading "Notable — +changed" in the turn ledger tells nobody anything, so a display rule may now +say `inLedger: false` and it is left out. + +Flags are the opposite: a flag flipping is a fact about the world and worth +seeing. But "the envelope: now true" is engine talk. A display rule can supply +`whenTrue` and `whenFalse` sentences — "The envelope is in your locker." — and +the ledger prints those instead of a delta. + +## 28. Exported logs live in logs/, and are not committed + +`logs/` holds exported feedback logs and `npm run analyze` reads the newest one +there when given no argument. The logs are gitignored: they record what a real +person did turn by turn, including anything they typed into the feedback box. +That is not repository content, and making it so once would put it in the +history for good. diff --git a/logs/README.md b/logs/README.md new file mode 100644 index 0000000..6b19e68 --- /dev/null +++ b/logs/README.md @@ -0,0 +1,13 @@ +# Exported playtest logs + +Drop exported feedback logs here — the browser downloads them wherever it +normally does, and this is where they go to be read. + +```sh +npm run analyze # the newest log in this folder +npm run analyze -- # a specific one +``` + +The logs themselves are gitignored. They record what a real person did, turn by +turn, including anything they typed into the feedback box, so they are not +committed: keep them local, or share them deliberately. diff --git a/package.json b/package.json index edc9cba..4c81a31 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { "name": "theladder", - "version": "0.0.1", + "version": "0.3.0", "private": true, "description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.", "type": "module", "scripts": { "test": "node --test \"test/**/*.test.js\"", - "serve": "python3 -m http.server 8080", - "build": "node tools/build.js", + "stamp": "node tools/stamp.js", + "serve": "node tools/stamp.js && python3 -m http.server 8080", + "build": "node tools/stamp.js && node tools/build.js", "analyze": "node tools/analyze-log.js" } } diff --git a/src/build-info.js b/src/build-info.js new file mode 100644 index 0000000..7e9e0ba --- /dev/null +++ b/src/build-info.js @@ -0,0 +1,7 @@ +// Stamped by tools/stamp.js, which runs before `npm run serve` and +// `npm run build`. The commit is whatever HEAD was at stamp time, so a dist +// file says exactly which commit produced it. +export const BUILD = { + commit: '4cab9fc+', + builtAt: '2026-09-10', +}; diff --git a/src/ui/app.js b/src/ui/app.js index 301234e..6def63b 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -16,7 +16,7 @@ import { resolveSchema } from '../engine/state.js'; import { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js'; import { turnScreen } from './screens/turn.js'; import { retrospectiveScreen } from './screens/retrospective.js'; -import { fill } from './dom.js'; +import { fill, focusMarked } from './dom.js'; import { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.js'; import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js'; import { downloadText, readFile, stamp } from '../io/download.js'; @@ -49,6 +49,9 @@ function render() { ? retrospectiveScreen(pack, app.state, schema, actions) : turnScreen(pack, app.state, schema, app.view, actions); fill(app.root, screen); + // After the screen is in the document, put the caret where the player most + // likely wants it. This is what makes the game playable on Enter alone. + focusMarked(app.root); } const actions = { diff --git a/src/ui/components.js b/src/ui/components.js index 586aaae..b4019fb 100644 --- a/src/ui/components.js +++ b/src/ui/components.js @@ -2,8 +2,27 @@ import { h } from './dom.js'; import { formatValue, labelFor, headerStats } from './format.js'; +import { APP, versionLine } from '../version.js'; import { getPath } from '../engine/paths.js'; +/** + * What you are playing, which pack, and who you currently are — plus the build, + * so a playtester can say which version they were on. + */ +export function statusBar(pack, state) { + const title = pack.stages?.[state.stage]?.title ?? state.stage; + return h('div', { class: 'statusbar' }, + h('div', { class: 'statusbar__what' }, + h('span', { class: 'statusbar__game' }, APP.name), + h('span', { class: 'statusbar__sep' }, '·'), + h('span', { class: 'statusbar__pack' }, pack.name ?? pack.id), + h('span', { class: 'statusbar__sep' }, '·'), + h('span', { class: 'statusbar__title' }, title), + ), + h('span', { class: 'statusbar__version', title: 'app version · build' }, versionLine()), + ); +} + /** A labelled bar for a bounded stat. */ export function meter(label, value, bounds, { compact = false } = {}) { const min = bounds?.min ?? 0; @@ -60,14 +79,29 @@ export function characterCard(pack, state, schema, character) { /** The change list shown after a choice: what moved, and by how much. */ export function changeList(pack, changes, { className = 'changes' } = {}) { - const meaningful = changes.filter((c) => c.from !== c.to); + const meaningful = changes.filter((change) => { + if (change.from === change.to) return false; + // Some state is bookkeeping the player has no use for — `notable` is the + // retrospective's own list, and reading "Notable — changed" in the ledger + // tells nobody anything. + return pack.display?.[change.path]?.inLedger !== false; + }); if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.'); return h('ul', { class: className }, meaningful.map((change) => { - const rising = typeof change.to === 'number' && typeof change.from === 'number' - ? change.to > change.from - : Boolean(change.to); + // A flag flipping is a fact about the world, not a quantity. Say it as a + // sentence the pack supplies rather than as "now true". + if (typeof change.to === 'boolean') { + const rule = pack.display?.[change.path]; + const phrase = (change.to ? rule?.whenTrue : rule?.whenFalse) + ?? `${labelFor(pack, change.path)}: ${change.to ? 'yes' : 'no longer'}`; + return h('li', { class: `change change--note change--${change.to ? 'up' : 'down'}` }, + h('span', { class: 'change__phrase' }, phrase), + ); + } + + const rising = change.to > change.from; return h('li', { class: `change change--${rising ? 'up' : 'down'}` }, // Upkeep changes carry the label of the entry that caused them — // "Rent", "Wages" — which is far more use than the path's own name. diff --git a/src/ui/dom.js b/src/ui/dom.js index 19157ac..28850d2 100644 --- a/src/ui/dom.js +++ b/src/ui/dom.js @@ -24,3 +24,25 @@ export function fill(container, ...children) { container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false)); return container; } + +/** + * Focus whatever the screen marked with `dataset.autofocus`. + * + * Every screen names the one control a player most likely wants next — the + * first available option while choosing, "next day" once the result is in — so + * a whole run can be played on Enter alone, with Tab and Shift+Tab to pick a + * different option. Disabled buttons are skipped by the browser's own tab + * order, so locked options are visible without being in the way. + */ +export function focusMarked(root) { + const queue = [root]; + while (queue.length) { + const node = queue.shift(); + if (node?.dataset?.autofocus) { + node.focus?.(); + return node; + } + queue.push(...(node?.childNodes ?? [])); + } + return null; +} diff --git a/src/ui/screens/retrospective.js b/src/ui/screens/retrospective.js index 4d8eeee..17e0be7 100644 --- a/src/ui/screens/retrospective.js +++ b/src/ui/screens/retrospective.js @@ -5,7 +5,7 @@ // not an ending: the player can close it and carry on from the same turn. import { h } from '../dom.js'; -import { statPanel, characterCard } from '../components.js'; +import { statPanel, characterCard, statusBar } from '../components.js'; import { formatMoney, labelFor } from '../format.js'; import { summarize } from '../summary.js'; @@ -14,6 +14,8 @@ export function retrospectiveScreen(pack, state, schema, actions) { const unit = pack.turnUnit ?? 'turn'; return h('div', { class: 'screen screen--retrospective' }, + statusBar(pack, state), + h('header', { class: 'retro__head' }, h('p', { class: 'retro__eyebrow' }, pack.name), h('h1', { class: 'retro__title' }, 'Career retrospective'), @@ -64,8 +66,11 @@ export function retrospectiveScreen(pack, state, schema, actions) { ), h('footer', { class: 'controls' }, - h('button', { class: 'button button--primary', onClick: actions.onResume }, - 'Keep going'), + h('button', { + class: 'button button--primary', + dataset: { autofocus: 'true' }, + onClick: actions.onResume, + }, 'Keep going'), h('button', { class: 'button button--quiet', onClick: actions.onExportSave }, 'Export save'), h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback }, 'Export feedback log'), diff --git a/src/ui/screens/turn.js b/src/ui/screens/turn.js index 3378c2d..5093492 100644 --- a/src/ui/screens/turn.js +++ b/src/ui/screens/turn.js @@ -10,13 +10,15 @@ // because this is the moment a player has an opinion. import { h } from '../dom.js'; -import { statPanel, characterCard, changeList } from '../components.js'; +import { statPanel, characterCard, changeList, statusBar } from '../components.js'; import { requirementText, turnHeading, upcomingUpkeep } from '../format.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), @@ -78,6 +80,9 @@ function diary(pack, state) { function choosingView(pack, view, actions) { const { event, options } = view.turn; 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' }, event.description), @@ -86,6 +91,7 @@ function choosingView(pack, view, actions) { 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' }, option.label), @@ -114,8 +120,11 @@ function outcomeView(pack, view, actions) { feedbackWidget(view, actions), - h('button', { class: 'button button--primary button--next', onClick: actions.onNext }, - `Next ${pack.turnUnit ?? 'turn'}`), + h('button', { + class: 'button button--primary button--next', + dataset: { autofocus: 'true' }, + onClick: actions.onNext, + }, `Next ${pack.turnUnit ?? 'turn'} →`), ); } diff --git a/src/ui/styles.css b/src/ui/styles.css index 0e9b72e..71fbe82 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -51,6 +51,24 @@ body { } .app { max-width: 1080px; margin: 0 auto; padding: 24px 20px 48px; } + +/* ---------- status bar ---------- */ + +.statusbar { + display: flex; flex-wrap: wrap; gap: 8px 16px; + align-items: baseline; justify-content: space-between; + margin-bottom: 18px; padding-bottom: 10px; + border-bottom: 1px solid var(--rule); + font-size: 12px; letter-spacing: 0.04em; +} +.statusbar__what { display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline; } +.statusbar__game { font-weight: 600; color: var(--ink); } +.statusbar__sep { color: var(--rule-strong); } +.statusbar__pack { color: var(--ink-soft); } +.statusbar__title { color: var(--accent); } +.statusbar__version { + font-variant-numeric: tabular-nums; color: var(--ink-faint); font-size: 11px; +} .booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; } /* ---------- head-up display ---------- */ @@ -178,6 +196,9 @@ body { .change--up .change__delta { color: var(--up); } .change--down .change__delta { color: var(--down); } .change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; } +.change--note .change__phrase { font-style: italic; } +.change--note.change--up .change__phrase { color: var(--up); } +.change--note.change--down .change__phrase { color: var(--ink-soft); } .button--next { margin-top: 8px; } diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..874728a --- /dev/null +++ b/src/version.js @@ -0,0 +1,16 @@ +// What is running. Shown on the main screen so a playtester can say which +// build they were on, and so it is obvious when new work has landed. + +import { BUILD } from './build-info.js'; + +export const APP = { + name: 'The Ladder', + version: '0.3.0', +}; + +/** "0.3.0 · a1b2c3d" — or "0.3.0 · dev" when running from source unstamped. */ +export function versionLine() { + return `${APP.version} · ${BUILD.commit}`; +} + +export { BUILD }; diff --git a/test/build.test.js b/test/build.test.js index 48d709f..7ff8a0f 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -24,6 +24,9 @@ test('the bundle builds, and the bundled game boots and plays', () => { assert.doesNotMatch(html, /type="module"/, 'no module scripts survive; file:// would block them'); assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain'); assert.match(html, /--paper:/, 'the CSS made it in'); + // A distributed file has to be able to say which commit made it. + assert.match(html, /commit: '[^']+'/, 'the build stamp is present'); + assert.doesNotMatch(html, /commit: 'dev'/, 'a built file is never stamped "dev"'); const script = html.match(/