Show the build, play from the keyboard, and clean up the ledger
Five things from the second playtest. A status line at the top of every screen: the game, the pack, your current title, and the version plus the commit it came 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. An uncommitted working tree gets a `+` suffix — "4cab9fc+" is honest in a way a bare SHA would not be. The whole game is now playable on Enter. Every screen marks one control 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. Tab and Shift+Tab reach the other options. Locked options needed no special handling — the browser's tab order already skips disabled controls, so they stay visible without being in the way. "What is notable? under What changed?" — two fixes. `notable` is the retrospective's own list of moments; a display rule can now say `inLedger: false` and it is left out, because "Notable — changed" tells nobody anything. Flags are the opposite and stay, but as sentences the pack supplies rather than as booleans: "The envelope is in your locker." instead of "the envelope: now true". Exported logs live in logs/, gitignored, and `npm run analyze` reads the newest one there when given no argument. A log records what a real person did turn by turn, including whatever they typed into the feedback box — committing one once would put it in the history for good. Version set to 0.3.0 on the reasoning that the engine was 0.1 and the interface 0.2; say if you want a different scheme and I will renumber. 119 tests. Reasoning in docs/DECISIONS.md §25-28. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj
This commit is contained in:
co-authored by
Claude Opus 5
parent
4cab9fc41e
commit
76072edf4e
@@ -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
|
||||
|
||||
@@ -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 <port>`
|
||||
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 -- <file> # 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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 -- <file> # 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.
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
+4
-1
@@ -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 = {
|
||||
|
||||
+38
-4
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
+12
-3
@@ -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'} →`),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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(/<script>([\s\S]*?)<\/script>/)?.[1];
|
||||
assert.ok(script, 'the bundled script is present');
|
||||
@@ -36,6 +39,7 @@ test('the bundle builds, and the bundled game boots and plays', () => {
|
||||
assert.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
|
||||
assert.match(dom.root.textContent, /Day 1/);
|
||||
assert.match(dom.root.textContent, /Marlene Hobb/);
|
||||
assert.match(dom.root.textContent, /The Ladder/, 'the status bar is in the bundle');
|
||||
|
||||
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
|
||||
assert.ok(playable.length > 0, 'the bundled game offers choices');
|
||||
|
||||
Vendored
+5
@@ -79,6 +79,10 @@ class FakeElement extends FakeNode {
|
||||
}
|
||||
|
||||
click() { this.dispatch('click'); }
|
||||
|
||||
focus() {
|
||||
if (globalThis.document) globalThis.document.activeElement = this;
|
||||
}
|
||||
}
|
||||
|
||||
function walk(node, out = []) {
|
||||
@@ -122,6 +126,7 @@ export function installFakeDom() {
|
||||
createTextNode: (text) => new FakeText(text),
|
||||
getElementById: (id) => (id === 'app' ? root : null),
|
||||
body: new FakeElement('body'),
|
||||
activeElement: null,
|
||||
};
|
||||
const listeners = {};
|
||||
const window = {
|
||||
|
||||
@@ -275,3 +275,94 @@ test('wages and rent appear in the ledger on the days they fall due', async () =
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the status bar says what you are playing, which pack, and who you are', async () => {
|
||||
const dom = await boot();
|
||||
try {
|
||||
const status = text(firstByClass(dom.root, 'statusbar'));
|
||||
assert.match(status, /The Ladder/, 'the game');
|
||||
assert.match(status, /Corporate Ladder/, 'the pack');
|
||||
assert.match(status, /Mail Associate I/, 'the current title');
|
||||
assert.match(status, /\d+\.\d+\.\d+ · /, 'the version and build');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the status bar title follows the player up a tier', async () => {
|
||||
const { statusBar } = await import('../src/ui/components.js');
|
||||
const { corporateLadder } = await import('../content/corporateladder/index.js');
|
||||
const dom = installFakeDom();
|
||||
try {
|
||||
const promoted = statusBar(corporateLadder, { stage: 'dispatch' });
|
||||
assert.match(promoted.textContent, /Dispatch Coordinator/);
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the keyboard alone can play a turn: focus lands where Enter should go', async () => {
|
||||
const dom = await boot();
|
||||
try {
|
||||
// Choosing: the first option a player can actually take.
|
||||
const firstPlayable = enabledOptions(dom.root)[0];
|
||||
assert.equal(dom.document.activeElement, firstPlayable,
|
||||
'the first available option is focused, so Enter takes it');
|
||||
|
||||
dom.document.activeElement.click();
|
||||
|
||||
// Outcome: the button that moves the run on.
|
||||
const next = firstByClass(dom.root, 'button--next');
|
||||
assert.equal(dom.document.activeElement, next,
|
||||
'the next-turn button is focused, so Enter advances');
|
||||
|
||||
dom.document.activeElement.click();
|
||||
assert.ok(firstByClass(dom.root, 'scenario'), 'and we are choosing again');
|
||||
assert.equal(dom.document.activeElement, enabledOptions(dom.root)[0]);
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('locked options are not in the tab order', async () => {
|
||||
const dom = await boot();
|
||||
try {
|
||||
// Disabled buttons are skipped by the browser's own tab handling; the
|
||||
// guarantee this test protects is that locked options stay disabled.
|
||||
for (let i = 0; i < 40; i++) {
|
||||
const locked = optionButtons(dom.root).filter((b) => b.disabled);
|
||||
if (locked.length) {
|
||||
assert.ok(locked.every((b) => b.disabled === true));
|
||||
return;
|
||||
}
|
||||
enabledOptions(dom.root)[0].click();
|
||||
firstByClass(dom.root, 'button--next').click();
|
||||
}
|
||||
assert.fail('no locked option appeared in 40 turns');
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('the ledger reads as English, and leaves bookkeeping out of it', async () => {
|
||||
const { changeList } = await import('../src/ui/components.js');
|
||||
const { corporateLadder: pack } = await import('../content/corporateladder/index.js');
|
||||
const dom = installFakeDom();
|
||||
try {
|
||||
const rendered = changeList(pack, [
|
||||
{ path: 'money', op: 'add', value: -20, from: 520, to: 500, clamped: false },
|
||||
{ path: 'flags.holds_the_envelope', op: 'set', value: true, from: false, to: true, clamped: false },
|
||||
{ path: 'notable', op: 'push', value: 'Kept an envelope.', from: [], to: ['Kept an envelope.'], clamped: false },
|
||||
]);
|
||||
const body = rendered.textContent;
|
||||
|
||||
assert.match(body, /Money/);
|
||||
assert.match(body, /−\$20/);
|
||||
assert.match(body, /The envelope is in your locker\./, 'a flag reads as a sentence');
|
||||
assert.doesNotMatch(body, /now true/, 'not as a boolean');
|
||||
assert.doesNotMatch(body, /Notable/, 'the retrospective\'s own list stays out of the ledger');
|
||||
assert.doesNotMatch(body, /Kept an envelope\./);
|
||||
} finally {
|
||||
dom.restore();
|
||||
}
|
||||
});
|
||||
|
||||
+27
-3
@@ -10,7 +10,29 @@
|
||||
// It also surfaces the two things worth more than any rating — the gates
|
||||
// players kept bouncing off, and anything they said they wanted to do.
|
||||
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { resolve, dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const logsDir = resolve(root, 'logs');
|
||||
|
||||
/** The most recently modified log in logs/, when none was named. */
|
||||
async function newestLog() {
|
||||
let names;
|
||||
try {
|
||||
names = (await readdir(logsDir)).filter((name) => name.endsWith('.json'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!names.length) return null;
|
||||
|
||||
const timed = await Promise.all(names.map(async (name) => {
|
||||
const path = join(logsDir, name);
|
||||
return { path, at: (await stat(path)).mtimeMs };
|
||||
}));
|
||||
return timed.sort((a, b) => b.at - a.at)[0].path;
|
||||
}
|
||||
|
||||
const bar = (fraction, width = 24) =>
|
||||
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
|
||||
@@ -147,12 +169,14 @@ function reportFeedback(entries) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const path = process.argv[2];
|
||||
const path = process.argv[2] ?? await newestLog();
|
||||
if (!path) {
|
||||
console.error('usage: node tools/analyze-log.js <exported-feedback-log.json>');
|
||||
console.error('usage: npm run analyze -- <exported-feedback-log.json>');
|
||||
console.error(' or: drop the export into logs/ and run npm run analyze');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
|
||||
|
||||
const log = JSON.parse(await readFile(path, 'utf8'));
|
||||
const entries = log.entries ?? [];
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Write the current commit into src/build-info.js.
|
||||
//
|
||||
// Runs before serving and before building, so what is on screen always names
|
||||
// the commit it came from. The file is tracked rather than generated-and-
|
||||
// ignored so that a fresh clone works without running anything first; it
|
||||
// changes at most once per commit, and only when the value actually differs.
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const target = resolve(root, 'src/build-info.js');
|
||||
|
||||
function commit() {
|
||||
try {
|
||||
const sha = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: root, stdio: 'pipe' })
|
||||
.toString().trim();
|
||||
const dirty = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' })
|
||||
.toString().trim().length > 0;
|
||||
return dirty ? `${sha}+` : sha;
|
||||
} catch {
|
||||
return 'dev';
|
||||
}
|
||||
}
|
||||
|
||||
const next = `// 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: '${commit()}',
|
||||
builtAt: '${new Date().toISOString().slice(0, 10)}',
|
||||
};
|
||||
`;
|
||||
|
||||
const current = readFileSync(target, 'utf8');
|
||||
// Only the date differs on a same-commit restamp; leave the file alone then,
|
||||
// so a working tree does not churn every time the server starts.
|
||||
const strip = (text) => text.replace(/builtAt: '[^']*'/, '');
|
||||
if (strip(current) !== strip(next)) {
|
||||
writeFileSync(target, next, 'utf8');
|
||||
console.log(`stamped build ${commit()}`);
|
||||
}
|
||||
Reference in New Issue
Block a user