Stamp the build into the output, not into a tracked file

The stamping scheme was wrong in a way worth recording. tools/stamp.js rewrote
src/build-info.js before every serve and build, so after each commit the
committed stamp named the *previous* commit — as it does right now, reading
76072ed+ while HEAD is 5c1784d — and the next serve rewrote it and dirtied the
tree again. A generated value does not belong in a tracked file if anything
routinely regenerates it.

src/build-info.js is now permanent and reads `commit: 'dev'`. tools/build.js
substitutes the real commit into the bundled output only, and fails loudly if
the substitution finds nothing to replace. So dist/theladder.html names the
commit that produced it, running from source honestly reads "0.3.0 · dev", and
the working tree never churns. tools/stamp.js is gone and `npm run serve` is a
plain static server again.

Also here, for picking this up later: a "Where things stand" section in the
README with the five open questions in the order they are likely to matter —
whether money stops mattering late, the negotiation gate that four early options
sit behind, the feedback widget nobody uses, how thin Dispatch is next to the
mailroom, and third-tier versus second-pack.

121 tests. docs/DECISIONS.md §25 corrected to describe what the code now does.

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 06:12:58 -04:00
co-authored by Claude Opus 5
parent 5c1784d990
commit 4957c24e31
6 changed files with 76 additions and 64 deletions
+25 -2
View File
@@ -53,8 +53,8 @@ npm run serve # then open http://localhost:8080
The game plays from the keyboard: Enter takes the focused option, Enter again 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 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 every screen names the game, the pack, your current title, and the build —
commit you are running. `0.3.0 · dev` when running from source, and the commit it came from when built.
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>`
@@ -88,6 +88,29 @@ 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.
## Where things stand
The MVP is complete and has survived three playtests. Two tiers, 31 events, a
retrospective, saves, and a feedback log that has already found one real bug.
Open questions, in the order they are likely to matter:
- **Does money stop mattering late?** A careful player ends around $8,300 over
200 turns. Tuning has been corrected twice from playtest logs and should be
judged from a session, not from simulated play — that is how the last trap got
in.
- **`skills.negotiation >= 15` gates four different early options.** The logs
show players bouncing off it repeatedly in the first thirty turns. It is
probably set too high for how slowly the skill grows.
- **Nobody uses the feedback widget.** Zero ratings across three sessions. The
locked-gate data in the log has been more useful than anything typed; the
widget may need to be more present, or may not need to exist.
- **Dispatch is eleven events against the mailroom's nineteen.** It passes the
variety guard, but it has had far less play than the mailroom.
- **The third tier, or the second content pack.** Promotion working entirely as
data is decent evidence the engine/content split holds; a second *setting*
would be the proof.
## Layout ## Layout
``` ```
+15 -8
View File
@@ -320,15 +320,22 @@ Playtest: *"need entry on the main screen showing what version we're running…
so it's easy to tell when new stuff is."* 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 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` current title, and the build.
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 `src/build-info.js` is tracked and permanently reads `commit: 'dev'`; it is
without a build step first. It changes at most once per commit, and the stamper never rewritten. `tools/build.js` substitutes the real commit into the *bundled
ignores a same-commit restamp so a running server does not churn the tree. output* only, so `dist/theladder.html` states exactly which commit produced it
while the working tree never churns. A build from a dirty tree gets a `+`
suffix — "5c1784d+" is honest in a way a bare SHA would not be, and the build
fails loudly if the substitution finds nothing to replace.
Running from source therefore reads "0.3.0 · dev", which is what it is.
The first attempt stamped the tracked file in place before every serve and
build. It was wrong in a way worth recording: after each commit the committed
stamp named the *previous* commit, and the next serve rewrote it and dirtied the
tree. A generated value does not belong in a tracked file if anything routinely
regenerates it.
## 26. The whole game is playable from the keyboard ## 26. The whole game is playable from the keyboard
+2 -3
View File
@@ -6,9 +6,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "node --test \"test/**/*.test.js\"", "test": "node --test \"test/**/*.test.js\"",
"stamp": "node tools/stamp.js", "serve": "python3 -m http.server 8080",
"serve": "node tools/stamp.js && python3 -m http.server 8080", "build": "node tools/build.js",
"build": "node tools/stamp.js && node tools/build.js",
"analyze": "node tools/analyze-log.js" "analyze": "node tools/analyze-log.js"
} }
} }
+8 -5
View File
@@ -1,7 +1,10 @@
// Stamped by tools/stamp.js, which runs before `npm run serve` and // The build identity shown in the status bar.
// `npm run build`. The commit is whatever HEAD was at stamp time, so a dist //
// file says exactly which commit produced it. // This file is never rewritten. Running from source is honestly "dev";
// tools/build.js substitutes the real commit into the bundled output only, so
// dist/theladder.html names the commit that produced it and the working tree
// never churns.
export const BUILD = { export const BUILD = {
commit: '76072ed+', commit: 'dev',
builtAt: '2026-09-10', builtAt: null,
}; };
+26 -2
View File
@@ -10,6 +10,7 @@
// a silent mis-bundle would be far worse than a failed build. // a silent mis-bundle would be far worse than a failed build.
import { readFile, writeFile, mkdir } from 'node:fs/promises'; import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { dirname, resolve, relative } from 'node:path'; import { dirname, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -89,6 +90,23 @@ async function collect(entryPath, seen = new Map()) {
return seen; return seen;
} }
/**
* The commit this build came from, marked `+` when the tree had uncommitted
* changes. Substituted into the output only — src/build-info.js stays as it is
* in the repository, so building never dirties the working tree.
*/
function buildIdentity() {
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 'unknown';
}
}
async function build() { async function build() {
const html = await readFile(entryHtml, 'utf8'); const html = await readFile(entryHtml, 'utf8');
@@ -130,15 +148,21 @@ async function build() {
'})();', '})();',
].join('\n'); ].join('\n');
const commit = buildIdentity();
const stamped = script
.replace("commit: 'dev'", `commit: '${commit}'`)
.replace('builtAt: null', `builtAt: '${new Date().toISOString().slice(0, 10)}'`);
if (stamped === script) throw new Error('could not stamp the build identity into the bundle');
const output = html const output = html
.replace(styleMatch[0], `<style>\n${css}\n </style>`) .replace(styleMatch[0], `<style>\n${css}\n </style>`)
.replace(scriptMatch[0], `<script>\n${script}\n </script>`); .replace(scriptMatch[0], `<script>\n${stamped}\n </script>`);
await mkdir(outDir, { recursive: true }); await mkdir(outDir, { recursive: true });
await writeFile(outFile, output, 'utf8'); await writeFile(outFile, output, 'utf8');
const kb = (Buffer.byteLength(output) / 1024).toFixed(1); const kb = (Buffer.byteLength(output) / 1024).toFixed(1);
console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB`); console.log(`built ${id(outFile)} — ${modules.size} modules, ${kb} kB, commit ${commit}`);
} }
build().catch((error) => { build().catch((error) => {
-44
View File
@@ -1,44 +0,0 @@
// 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()}`);
}