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/
|
node_modules/
|
||||||
dist/
|
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
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ currently playable only from a test harness.
|
|||||||
| Calendar, scheduled wages and rent | done |
|
| Calendar, scheduled wages and rent | done |
|
||||||
| Promotion from the mailroom to Dispatch | done |
|
| Promotion from the mailroom to Dispatch | done |
|
||||||
| Repetition analytics (`npm run analyze`) | 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 |
|
| Played in a real browser | done — first playtest 2026-09-09 |
|
||||||
|
|
||||||
The first playtest confirmed the interface, saves and the exported log all work
|
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
|
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
|
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>`
|
over `file://`. If port 8080 is taken, run `python3 -m http.server <port>`
|
||||||
instead.
|
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.
|
It plays by double-clicking it — no server, no network, nothing installed.
|
||||||
|
|
||||||
```sh
|
```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
|
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:
|
typed into the feedback box:
|
||||||
|
|
||||||
```sh
|
```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
|
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.
|
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 },
|
'reputation': { label: 'Standing', format: 'meter', order: 3 },
|
||||||
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
|
'skills.negotiation': { label: 'Negotiation', format: 'meter', order: 4 },
|
||||||
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
|
'skills.logistics': { label: 'Logistics', format: 'meter', order: 5 },
|
||||||
'notable': { hidden: true },
|
// Kept out of the header, and out of the ledger too: `notable` is the
|
||||||
'flags.took_loan': { label: 'a loan on the books', hidden: true },
|
// retrospective's own list, and "Notable — changed" tells nobody anything.
|
||||||
'flags.holds_the_envelope': { label: 'the envelope', hidden: true },
|
'notable': { hidden: true, inLedger: false },
|
||||||
'flags.covered_for_trevor': { label: "Trevor's Thursday", hidden: true },
|
// Flags do belong in the ledger — a flag flipping is a fact about the
|
||||||
'flags.promoted': { label: 'a promotion', hidden: true },
|
// world — but as a sentence rather than as "now true".
|
||||||
'flags.recommended_trevor': { label: "Trevor's recommendation", hidden: 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,
|
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
|
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
|
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.
|
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",
|
"name": "theladder",
|
||||||
"version": "0.0.1",
|
"version": "0.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
"description": "A turn-based career simulation game. Engine is theme-agnostic; settings are content packs.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "node --test \"test/**/*.test.js\"",
|
"test": "node --test \"test/**/*.test.js\"",
|
||||||
"serve": "python3 -m http.server 8080",
|
"stamp": "node tools/stamp.js",
|
||||||
"build": "node tools/build.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"
|
"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 { startGame, currentTurn, takeTurn, findOptionById } from './engine-bridge.js';
|
||||||
import { turnScreen } from './screens/turn.js';
|
import { turnScreen } from './screens/turn.js';
|
||||||
import { retrospectiveScreen } from './screens/retrospective.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 { saveLocal, loadLocal, clearLocal, serializeSave, parseSave, storageAvailable } from '../io/save.js';
|
||||||
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
|
import { logTurn, attachFeedback, readLog, serializeLog, clearLog, feedbackCount } from '../io/feedback-log.js';
|
||||||
import { downloadText, readFile, stamp } from '../io/download.js';
|
import { downloadText, readFile, stamp } from '../io/download.js';
|
||||||
@@ -49,6 +49,9 @@ function render() {
|
|||||||
? retrospectiveScreen(pack, app.state, schema, actions)
|
? retrospectiveScreen(pack, app.state, schema, actions)
|
||||||
: turnScreen(pack, app.state, schema, app.view, actions);
|
: turnScreen(pack, app.state, schema, app.view, actions);
|
||||||
fill(app.root, screen);
|
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 = {
|
const actions = {
|
||||||
|
|||||||
+38
-4
@@ -2,8 +2,27 @@
|
|||||||
|
|
||||||
import { h } from './dom.js';
|
import { h } from './dom.js';
|
||||||
import { formatValue, labelFor, headerStats } from './format.js';
|
import { formatValue, labelFor, headerStats } from './format.js';
|
||||||
|
import { APP, versionLine } from '../version.js';
|
||||||
import { getPath } from '../engine/paths.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. */
|
/** A labelled bar for a bounded stat. */
|
||||||
export function meter(label, value, bounds, { compact = false } = {}) {
|
export function meter(label, value, bounds, { compact = false } = {}) {
|
||||||
const min = bounds?.min ?? 0;
|
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. */
|
/** The change list shown after a choice: what moved, and by how much. */
|
||||||
export function changeList(pack, changes, { className = 'changes' } = {}) {
|
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.');
|
if (!meaningful.length) return h('p', { class: 'changes changes--none' }, 'Nothing measurable changed.');
|
||||||
|
|
||||||
return h('ul', { class: className },
|
return h('ul', { class: className },
|
||||||
meaningful.map((change) => {
|
meaningful.map((change) => {
|
||||||
const rising = typeof change.to === 'number' && typeof change.from === 'number'
|
// A flag flipping is a fact about the world, not a quantity. Say it as a
|
||||||
? change.to > change.from
|
// sentence the pack supplies rather than as "now true".
|
||||||
: Boolean(change.to);
|
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'}` },
|
return h('li', { class: `change change--${rising ? 'up' : 'down'}` },
|
||||||
// Upkeep changes carry the label of the entry that caused them —
|
// Upkeep changes carry the label of the entry that caused them —
|
||||||
// "Rent", "Wages" — which is far more use than the path's own name.
|
// "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));
|
container.replaceChildren(...children.flat(Infinity).filter((c) => c !== null && c !== undefined && c !== false));
|
||||||
return container;
|
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.
|
// not an ending: the player can close it and carry on from the same turn.
|
||||||
|
|
||||||
import { h } from '../dom.js';
|
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 { formatMoney, labelFor } from '../format.js';
|
||||||
import { summarize } from '../summary.js';
|
import { summarize } from '../summary.js';
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
|||||||
const unit = pack.turnUnit ?? 'turn';
|
const unit = pack.turnUnit ?? 'turn';
|
||||||
|
|
||||||
return h('div', { class: 'screen screen--retrospective' },
|
return h('div', { class: 'screen screen--retrospective' },
|
||||||
|
statusBar(pack, state),
|
||||||
|
|
||||||
h('header', { class: 'retro__head' },
|
h('header', { class: 'retro__head' },
|
||||||
h('p', { class: 'retro__eyebrow' }, pack.name),
|
h('p', { class: 'retro__eyebrow' }, pack.name),
|
||||||
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
h('h1', { class: 'retro__title' }, 'Career retrospective'),
|
||||||
@@ -64,8 +66,11 @@ export function retrospectiveScreen(pack, state, schema, actions) {
|
|||||||
),
|
),
|
||||||
|
|
||||||
h('footer', { class: 'controls' },
|
h('footer', { class: 'controls' },
|
||||||
h('button', { class: 'button button--primary', onClick: actions.onResume },
|
h('button', {
|
||||||
'Keep going'),
|
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.onExportSave }, 'Export save'),
|
||||||
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
h('button', { class: 'button button--quiet', onClick: actions.onExportFeedback },
|
||||||
'Export feedback log'),
|
'Export feedback log'),
|
||||||
|
|||||||
+12
-3
@@ -10,13 +10,15 @@
|
|||||||
// because this is the moment a player has an opinion.
|
// because this is the moment a player has an opinion.
|
||||||
|
|
||||||
import { h } from '../dom.js';
|
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';
|
import { requirementText, turnHeading, upcomingUpkeep } from '../format.js';
|
||||||
|
|
||||||
export function turnScreen(pack, state, schema, view, actions) {
|
export function turnScreen(pack, state, schema, view, actions) {
|
||||||
const heading = turnHeading(pack, state);
|
const heading = turnHeading(pack, state);
|
||||||
|
|
||||||
return h('div', { class: 'screen screen--turn' },
|
return h('div', { class: 'screen screen--turn' },
|
||||||
|
statusBar(pack, state),
|
||||||
|
|
||||||
h('header', { class: 'hud' },
|
h('header', { class: 'hud' },
|
||||||
h('div', { class: 'hud__where' },
|
h('div', { class: 'hud__where' },
|
||||||
h('p', { class: 'hud__stage' }, pack.stages?.[state.stage]?.title ?? state.stage),
|
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) {
|
function choosingView(pack, view, actions) {
|
||||||
const { event, options } = view.turn;
|
const { event, options } = view.turn;
|
||||||
const visible = options.filter((o) => !o.hidden);
|
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' },
|
return h('section', { class: 'scenario' },
|
||||||
h('p', { class: 'scenario__text' }, event.description),
|
h('p', { class: 'scenario__text' }, event.description),
|
||||||
@@ -86,6 +91,7 @@ function choosingView(pack, view, actions) {
|
|||||||
h('button', {
|
h('button', {
|
||||||
class: `option${available ? '' : ' option--locked'}`,
|
class: `option${available ? '' : ' option--locked'}`,
|
||||||
disabled: !available,
|
disabled: !available,
|
||||||
|
dataset: option.id === firstAvailable ? { autofocus: 'true' } : {},
|
||||||
onClick: available ? () => actions.onChoose(option.id) : null,
|
onClick: available ? () => actions.onChoose(option.id) : null,
|
||||||
},
|
},
|
||||||
h('span', { class: 'option__label' }, option.label),
|
h('span', { class: 'option__label' }, option.label),
|
||||||
@@ -114,8 +120,11 @@ function outcomeView(pack, view, actions) {
|
|||||||
|
|
||||||
feedbackWidget(view, actions),
|
feedbackWidget(view, actions),
|
||||||
|
|
||||||
h('button', { class: 'button button--primary button--next', onClick: actions.onNext },
|
h('button', {
|
||||||
`Next ${pack.turnUnit ?? 'turn'}`),
|
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; }
|
.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; }
|
.booting { color: var(--ink-faint); padding: 48px 20px; text-align: center; }
|
||||||
|
|
||||||
/* ---------- head-up display ---------- */
|
/* ---------- head-up display ---------- */
|
||||||
@@ -178,6 +196,9 @@ body {
|
|||||||
.change--up .change__delta { color: var(--up); }
|
.change--up .change__delta { color: var(--up); }
|
||||||
.change--down .change__delta { color: var(--down); }
|
.change--down .change__delta { color: var(--down); }
|
||||||
.change__note { font-size: 11px; color: var(--ink-faint); font-style: italic; }
|
.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; }
|
.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, /type="module"/, 'no module scripts survive; file:// would block them');
|
||||||
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
|
assert.doesNotMatch(html, /^[ \t]*import\s/m, 'no bare import statements remain');
|
||||||
assert.match(html, /--paper:/, 'the CSS made it in');
|
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];
|
const script = html.match(/<script>([\s\S]*?)<\/script>/)?.[1];
|
||||||
assert.ok(script, 'the bundled script is present');
|
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.ok(firstByClass(dom.root, 'screen--turn'), 'the bundled game rendered a turn');
|
||||||
assert.match(dom.root.textContent, /Day 1/);
|
assert.match(dom.root.textContent, /Day 1/);
|
||||||
assert.match(dom.root.textContent, /Marlene Hobb/);
|
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);
|
const playable = byClass(dom.root, 'option').filter((b) => !b.disabled);
|
||||||
assert.ok(playable.length > 0, 'the bundled game offers choices');
|
assert.ok(playable.length > 0, 'the bundled game offers choices');
|
||||||
|
|||||||
Vendored
+5
@@ -79,6 +79,10 @@ class FakeElement extends FakeNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
click() { this.dispatch('click'); }
|
click() { this.dispatch('click'); }
|
||||||
|
|
||||||
|
focus() {
|
||||||
|
if (globalThis.document) globalThis.document.activeElement = this;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function walk(node, out = []) {
|
function walk(node, out = []) {
|
||||||
@@ -122,6 +126,7 @@ export function installFakeDom() {
|
|||||||
createTextNode: (text) => new FakeText(text),
|
createTextNode: (text) => new FakeText(text),
|
||||||
getElementById: (id) => (id === 'app' ? root : null),
|
getElementById: (id) => (id === 'app' ? root : null),
|
||||||
body: new FakeElement('body'),
|
body: new FakeElement('body'),
|
||||||
|
activeElement: null,
|
||||||
};
|
};
|
||||||
const listeners = {};
|
const listeners = {};
|
||||||
const window = {
|
const window = {
|
||||||
|
|||||||
@@ -275,3 +275,94 @@ test('wages and rent appear in the ledger on the days they fall due', async () =
|
|||||||
dom.restore();
|
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
|
// 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.
|
// 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) =>
|
const bar = (fraction, width = 24) =>
|
||||||
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
|
'█'.repeat(Math.max(1, Math.round(fraction * width))).padEnd(width, '·');
|
||||||
@@ -147,12 +169,14 @@ function reportFeedback(entries) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const path = process.argv[2];
|
const path = process.argv[2] ?? await newestLog();
|
||||||
if (!path) {
|
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;
|
process.exitCode = 1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!process.argv[2]) console.log(`reading ${path.replace(`${root}/`, '')}\n`);
|
||||||
|
|
||||||
const log = JSON.parse(await readFile(path, 'utf8'));
|
const log = JSON.parse(await readFile(path, 'utf8'));
|
||||||
const entries = log.entries ?? [];
|
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