From 4957c24e317db533d182b80d9eb7bc7a2e7c44d3 Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Thu, 10 Sep 2026 06:12:58 -0400 Subject: [PATCH] Stamp the build into the output, not into a tracked file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VMSFHyVPitUoosW5wyEADj --- README.md | 27 +++++++++++++++++++++++++-- docs/DECISIONS.md | 23 +++++++++++++++-------- package.json | 5 ++--- src/build-info.js | 13 ++++++++----- tools/build.js | 28 ++++++++++++++++++++++++++-- tools/stamp.js | 44 -------------------------------------------- 6 files changed, 76 insertions(+), 64 deletions(-) delete mode 100644 tools/stamp.js diff --git a/README.md b/README.md index d7d1873..dc5b8e6 100644 --- a/README.md +++ b/README.md @@ -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 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. +every screen names the game, the pack, your current title, and the build — +`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 over `file://`. If port 8080 is taken, run `python3 -m http.server ` @@ -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 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 ``` diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a8220a9..e0997cc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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."* 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. +current title, and the build. -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. +`src/build-info.js` is tracked and permanently reads `commit: 'dev'`; it is +never rewritten. `tools/build.js` substitutes the real commit into the *bundled +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 diff --git a/package.json b/package.json index 4c81a31..3bcbd00 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,8 @@ "type": "module", "scripts": { "test": "node --test \"test/**/*.test.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", + "serve": "python3 -m http.server 8080", + "build": "node tools/build.js", "analyze": "node tools/analyze-log.js" } } diff --git a/src/build-info.js b/src/build-info.js index 1fc3b07..62c96e3 100644 --- a/src/build-info.js +++ b/src/build-info.js @@ -1,7 +1,10 @@ -// 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. +// The build identity shown in the status bar. +// +// 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 = { - commit: '76072ed+', - builtAt: '2026-09-10', + commit: 'dev', + builtAt: null, }; diff --git a/tools/build.js b/tools/build.js index 9e8bd9e..d7ec36d 100644 --- a/tools/build.js +++ b/tools/build.js @@ -10,6 +10,7 @@ // a silent mis-bundle would be far worse than a failed build. import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { execFileSync } from 'node:child_process'; import { dirname, resolve, relative } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -89,6 +90,23 @@ async function collect(entryPath, seen = new Map()) { 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() { const html = await readFile(entryHtml, 'utf8'); @@ -130,15 +148,21 @@ async function build() { '})();', ].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 .replace(styleMatch[0], ``) - .replace(scriptMatch[0], ``); + .replace(scriptMatch[0], ``); await mkdir(outDir, { recursive: true }); await writeFile(outFile, output, 'utf8'); 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) => { diff --git a/tools/stamp.js b/tools/stamp.js deleted file mode 100644 index 4338cc0..0000000 --- a/tools/stamp.js +++ /dev/null @@ -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()}`); -}