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:
co-authored by
Claude Opus 5
parent
5c1784d990
commit
4957c24e31
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||||
|
|||||||
@@ -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()}`);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user