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:
JesseMarkowitz
2026-09-10 05:41:32 -04:00
co-authored by Claude Opus 5
parent 4cab9fc41e
commit 76072edf4e
19 changed files with 416 additions and 25 deletions
+91
View File
@@ -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();
}
});