fuzzyworld

explodorama / commit

Version number, build commit and source link in the menu

0b25f1ef71ac900a5847ed0c496aca2b9281d2d1 · Pavel Safronov · 2026-10-24 00:28:48 -0700 · parents 0580e85e239c

Claude Code usage: this commit ~24.4K out / ~2.4M cached (first commit of its session: everything since it began) · cost unknown (no per-model trailer) · session 4 tally 100 in / 24.4K out / 2.4M cached · usage by commit and session

Version number, build commit and source link in the menu

The version is SIM_VERSION (22), shown at the bottom of the menu and near the
top of the README; a test keeps the two in step. The menu also shows the short
commit hash the build came from (read from git by vite.config.ts, linked to
GitLab, with "plus local changes" for a dirty tree) and a Source code link.

Usage: 0.1K in / 24.4K out / 2.4M cached (session tally)
rtk:   1.8M saved, 39.8% over 3525 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 CHANGELOG.md         |  3 +++
 CONTRIBUTING.md      |  3 ++-
 README.md            |  7 +++++++
 decisions.md         | 17 +++++++++++++++++
 src/main.ts          | 15 ++++++++++++++-
 test/version.test.ts | 11 +++++++++++
 vite.config.ts       | 15 +++++++++++++++
 7 files changed, 69 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index bc91ad3..4093cbc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,9 @@ saved before the change is dropped and the next visit starts a new run.
 
 ### Added
 
+- **A version number**, at the bottom of the menu and near the top of the README. It is the
+  rules version (`SIM_VERSION`), so it changes exactly when old saved runs stop resuming. The
+  menu also shows the commit the game was built from and links to the source code.
 - **A high-score board** in this browser: every finished run, ranked by how far it got, then
   coins, with enemies killed, turns, how it ended, the power-ups held and the seed. A global
   board and a daily dungeon are planned.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 5c7cea0..e311d67 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -54,7 +54,8 @@ The README's *How it's built* section describes the layout. The rules that matte
   the same state.
 - **Bump `SIM_VERSION`** in [src/sim/record.ts](src/sim/record.ts) whenever a rule change
   would make an old record replay differently. Saved runs from another version are dropped
-  rather than replayed wrongly. Note the bump in the changelog.
+  rather than replayed wrongly. Note the bump in the changelog, and update the version near
+  the top of the README: it is the game's version, also shown in the menu.
 - **Rendering** (`src/render/`) and the **HTML UI** (`src/ui/`) only read the state and the
   events that `step()` returns; they never change the rules.
 - **Movement on screen comes from the change of state, not from events.** The player's and the
diff --git a/README.md b/README.md
index 93166f1..3cb681b 100644
--- a/README.md
+++ b/README.md
@@ -11,6 +11,13 @@ seven charges that work when picked up, a hidden gem on every level that offers
 mid-run work. The shop, the Keep / Fresh choice on death, curses and more enemies are planned —
 see [PLAN.md](PLAN.md).
 
+Version: **22**, shown at the bottom of the menu with the commit the game was built from (a link
+to it on GitLab, plus "plus local changes" in a build with uncommitted changes) and a link to the
+[source code](https://gitlab.com/PavelSafronov/explodorama). The version is the rules version
+(`SIM_VERSION` in [src/sim/record.ts](src/sim/record.ts)): it goes up by one whenever a rule
+change would make an old run replay differently. A run saved under another version is not
+resumed, and a seed gives the same dungeon only within one version.
+
 ![The board on a phone, with a bomb counting down](docs/screenshots/countdown.png)
 
 ## How to play
diff --git a/decisions.md b/decisions.md
index 45d0d60..3a25af2 100644
--- a/decisions.md
+++ b/decisions.md
@@ -1073,3 +1073,20 @@ start thinking about a daily dungeon with a seed from the date.
 
 Checked in a browser: two game overs (the second a Developer run with more coins) each said "New
 high score: #1!"; the board showed both, the Developer one first, red-bordered.
+
+### 2026-10-24 — The version number is the rules version
+
+User request: a version number in the menu and the README, using the `SIM_VERSION` counter.
+
+- **One number:** the game's version is `SIM_VERSION` (now 22), not `package.json`'s 0.1.0,
+  which nothing bumps. It goes up exactly when a rule change makes old runs replay differently,
+  so it also says which saved runs and seeds carry over.
+- **Where:** a small line at the bottom of the menu, under the art credit, and a "Version:" line
+  near the top of the README. A test (`test/version.test.ts`) fails if the README's number and
+  `SIM_VERSION` disagree, so a bump can't leave the README behind.
+- A change that alters only the look (no rule change) keeps the same version.
+- **The commit and the repository** (user request, same day): the menu also shows the short
+  commit hash the build came from, linked to it on GitLab, and a "Source code" link. The hash
+  is read from git when Vite starts (`vite.config.ts`, as `VITE_COMMIT`), so the deployed build
+  shows the commit CI built. A build with uncommitted changes says "plus local changes", since
+  the hash alone would claim code it doesn't contain; with no git, the commit is left out.
diff --git a/src/main.ts b/src/main.ts
index 4a9424b..36a42f1 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -341,6 +341,16 @@ function showInfo(info: Info, extra: DialogButton[] = []): void {
   })
 }
 
+const REPO_URL = 'https://gitlab.com/PavelSafronov/explodorama'
+
+/** ", commit 0580e85" linked to it on GitLab, with a note if built with uncommitted changes. */
+function commitLine(): string {
+  const commit = import.meta.env.VITE_COMMIT
+  if (!commit) return ''
+  const link = `<a href="${REPO_URL}/-/commit/${commit}" target="_blank" rel="noopener">${commit}</a>`
+  return `, commit ${link}${import.meta.env.VITE_COMMIT_DIRTY ? ' plus local changes' : ''}`
+}
+
 /** The menu: resume, or abandon this run for a new one (confirmed first, since it can't be undone). */
 function showMenu(): void {
   const where = state.status === 'playing' ? `Level ${state.level}, turn ${state.turn}. ` : ''
@@ -350,7 +360,10 @@ function showMenu(): void {
       `<p>${where}Seed ${state.seed}. The game saves after every move, so you can close it and come back.</p>` +
       modeLines(state).map((l) => `<p class="mode">${l}</p>`).join('') +
       `<p class="credit">Art by <a href="https://www.kenney.nl" target="_blank" rel="noopener">Kenney</a>: ` +
-      `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>`,
+      `New Platformer Pack, UI Pack, UI Pack: RPG Expansion, Game Icons and Board Game Icons (CC0). Thank you!</p>` +
+      // The version is the rules version: it changes exactly when old saves stop resuming.
+      `<p class="credit">Version ${SIM_VERSION}${commitLine()}. ` +
+      `<a href="${REPO_URL}" target="_blank" rel="noopener">Source code</a></p>`,
     buttons: [
       { label: 'Resume', kind: 'primary', icon: playIcon },
       { label: 'Almanac', kind: 'plain', onClick: () => openAlmanac() },
diff --git a/test/version.test.ts b/test/version.test.ts
new file mode 100644
index 0000000..01d1a0a
--- /dev/null
+++ b/test/version.test.ts
@@ -0,0 +1,11 @@
+import { readFileSync } from 'node:fs'
+import { describe, expect, it } from 'vitest'
+import { SIM_VERSION } from '../src/sim/record'
+
+describe('version', () => {
+  // The game's version is SIM_VERSION (shown in the menu); the README states it by hand.
+  it('the README states the current SIM_VERSION', () => {
+    const stated = readFileSync('README.md', 'utf8').match(/^Version: \*\*(\d+)\*\*/m)?.[1]
+    expect(Number(stated)).toBe(SIM_VERSION)
+  })
+})
diff --git a/vite.config.ts b/vite.config.ts
index a22b448..eea955f 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,5 +1,6 @@
 import { defineConfig } from 'vite'
 import { qrcode } from 'vite-plugin-qrcode'
+import { execSync } from 'node:child_process'
 
 /**
  * Relative asset paths by default, so the same build works wherever Pages puts it:
@@ -8,6 +9,20 @@ import { qrcode } from 'vite-plugin-qrcode'
  */
 const base = process.env.BASE || './'
 
+/** A git command's output, or '' where there is no git (the menu then leaves the commit out). */
+function git(args: string): string {
+  try {
+    return execSync(`git ${args}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
+  } catch {
+    return ''
+  }
+}
+
+// The commit the game was built from, shown in the menu with the version. Vite hands VITE_*
+// variables to the game as import.meta.env. DIRTY: the build has uncommitted changes (in dev).
+process.env.VITE_COMMIT = git('rev-parse --short HEAD')
+process.env.VITE_COMMIT_DIRTY = git('status --porcelain') ? '1' : ''
+
 export default defineConfig({
   base,
   // `npm run dev` and `npm run preview` bind to every interface (--host) so a phone on the same