From acd3acfe003dba32bcd6f5d20653b1a6c77ec63b Mon Sep 17 00:00:00 2001 From: McDope Date: Wed, 26 Aug 2026 22:07:53 +0200 Subject: [PATCH 1/2] chore: gitignore the launch PR kit directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pr/` holds ready-to-post launch copy for seven platforms plus the images each one needs. It is drafts and captured screenshots: the durable artifacts are the posts themselves, wherever they end up published, not a snapshot of what was pasted. Same rule the docs-audit/ and level_maps/ entries already follow. Ignored rather than merely untracked because run-balancing-capture.mjs refuses to start on any non-empty `git status --porcelain`, untracked files included — so an unignored scratch directory would block a capture while a kit is being written. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 99c28cff..70f72105 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,10 @@ doc/dev/remediation-order-*.md # since moved. Ignored rather than deleted so a run in progress keeps a clean # tree for the capture guard. docs-audit/ + +# Launch/press copy and its images (pr/README.md indexes it). Written once for a +# release announcement, then edited by hand and pasted into each platform — the +# durable artifacts are the posts themselves, wherever they end up published, not +# a snapshot of the drafts. Ignored rather than untracked so the capture guard in +# run-balancing-capture.mjs still sees a clean tree while a kit is being written. +pr/ From beeeca7fec4bb1693549837d427eb843347f9baa Mon Sep 17 00:00:00 2001 From: McDope Date: Wed, 26 Aug 2026 22:41:23 +0200 Subject: [PATCH 2/2] docs: correct six stale comments found by a fact-check pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit None of these change behaviour; all six describe the code as it was, not as it is. They surfaced while three independent agents fact-checked a launch-copy draft against the repo, which is the only reason anyone read them closely. - lore.ts / enemies.ts still gave `HP_PER_COMPLEXITY` as 25; it has been 35 since the enemy-health pass. The enemies.ts comment was wrong twice over — it also called the Edge Case roll "10-15", which is now 25-35. Both are fixed by naming the constants instead of restating their values, so the same drift cannot recur; the two *deliberate* historical references to 25 (weapons.ts, combatConstants.ts, both describing the 25 -> 35 change itself) are untouched. - mapGenerator.ts claimed Edge Cases are "never a normal room, and normal enemies never spawn in one". The converse half stopped being true on 2026-08-20: `edgeCase` is now set in three places, and `types.ts` already carries the corrected wording. What remains true, and is what the comment was reaching for, is that `spawnEdgeCaseEnemies` populates the corridor features exclusively and is the only thing that populates them. - main.ts headed the launch tabs "Local / Continue / GitHub / Demo level". The tab is called Repo and takes GitLab and Codeberg too. Its elements were renamed with it; only the `LaunchTab` key is still `github`, so the comment now says so rather than leaving a reader to wonder which name is wrong. - decisions.md called `assertAllRoomsReachable` "a permanent dev-time BFS assertion". It is deliberately not gated behind `import.meta.env.DEV` — the source says so, and says why: a shipped build is exactly where nothing else would catch an unreachable room. It also `console.error`s rather than throws, so "assertion" was the wrong word. - vitest.config.ts advertised coverage of 99.94/99.79/99.62/99.97%. Measured on master today it is 99.9083/99.5225/99.84/99.99. Restated in units rather than percentages because that is the part worth knowing: statements sit at 13,073/13,085 and branches at 5,627/5,654, so each can afford exactly one more uncovered unit before the gate fails. That last figure is itself a correction of a first draft of this commit, which said "a single new uncovered statement fails" from a measurement taken on a branch carrying extra code. The arithmetic says two. Co-Authored-By: Claude Opus 5 (1M context) --- doc/dev/decisions.md | 2 +- src/main.ts | 8 ++++++-- src/map/generation/enemies.ts | 7 ++++--- src/map/generation/lore.ts | 2 +- src/map/mapGenerator.ts | 8 ++++++-- vitest.config.ts | 16 +++++++++++++--- 6 files changed, 31 insertions(+), 12 deletions(-) diff --git a/doc/dev/decisions.md b/doc/dev/decisions.md index a7d43c47..9b138079 100644 --- a/doc/dev/decisions.md +++ b/doc/dev/decisions.md @@ -233,7 +233,7 @@ the door rather than slowing down. That is also why there is no key loot drop. ## Room Connectivity Is a Hard Guarantee, Corridor Length Isn't -Every room being reachable from spawn was assumed to follow automatically from `connectRooms` chaining rooms 0..n-1, but two gaps meant it didn't always hold: `placeRooms` could end a level with a single room (an empty file, or one entity that's the only one that fits), and `connectRooms` only carves a corridor once a *second* room exists, so that lone room silently got zero corridors; separately, `carveLabyrinth`'s recursive-division maze (for any entity nested ≥2 deep) kept exactly one connecting gap per split, but a child split's own later wall could land on the exact cell a parent split's gap depended on to reach it, sealing off part of a room despite every individual split "keeping a gap" — confirmed empirically at roughly 9% of seeds across the room shapes/nesting depths generation actually produces. Fixed with a room-count floor (`placeFillerRoom`, topping a level up to at least 2 rooms; the synthetic filler/fallback entity uses `kind: "class"` rather than `"function"` so it can never spawn an enemy whose nameplate would show a placeholder name) and a post-carve labyrinth connectivity repair (flood-fill the room's floor into components, bridge the closest pair via a shortest wall-crossing BFS, repeat until one component remains) — both deterministic, no extra RNG draws, so neither perturbs existing replay determinism. A permanent dev-time BFS assertion (`assertAllRoomsReachable`, end of every `generate()` call) now also checks this invariant and logs loudly if it's ever violated again rather than shipping a silently broken level. This is a deliberately different guarantee level from [Corridor Dressing: A Vocabulary, Best-Effort](#corridor-dressing-a-vocabulary-best-effort) above: a corridor's *length* staying under the nominal cap is still explicitly best-effort, but a corridor *existing at all* between every room and the exit is now a hard invariant, not a probabilistic one. `connectLoops`' shortcuts are held to the same line — they only ever add edges, so they cannot weaken the invariant, and `assertAllRoomsReachable` still runs after them. `[notes: sometimes rooms without exit are generated item]` +Every room being reachable from spawn was assumed to follow automatically from `connectRooms` chaining rooms 0..n-1, but two gaps meant it didn't always hold: `placeRooms` could end a level with a single room (an empty file, or one entity that's the only one that fits), and `connectRooms` only carves a corridor once a *second* room exists, so that lone room silently got zero corridors; separately, `carveLabyrinth`'s recursive-division maze (for any entity nested ≥2 deep) kept exactly one connecting gap per split, but a child split's own later wall could land on the exact cell a parent split's gap depended on to reach it, sealing off part of a room despite every individual split "keeping a gap" — confirmed empirically at roughly 9% of seeds across the room shapes/nesting depths generation actually produces. Fixed with a room-count floor (`placeFillerRoom`, topping a level up to at least 2 rooms; the synthetic filler/fallback entity uses `kind: "class"` rather than `"function"` so it can never spawn an enemy whose nameplate would show a placeholder name) and a post-carve labyrinth connectivity repair (flood-fill the room's floor into components, bridge the closest pair via a shortest wall-crossing BFS, repeat until one component remains) — both deterministic, no extra RNG draws, so neither perturbs existing replay determinism. A permanent BFS check (`assertAllRoomsReachable`, end of every `generate()` call) now also verifies this invariant and logs loudly if it's ever violated again rather than shipping a silently broken level. **It is deliberately *not* gated behind `import.meta.env.DEV`** — a shipped build is exactly where nothing else would catch a violation, since an unreachable room is unwinnable and silently so; the cost is one BFS per door on a pass that already does far more work than that, paid once per level load rather than per frame. (This entry described it as "dev-time" until 2026-08-26, which the source has contradicted since the check was written. Note also that it `console.error`s and returns rather than throwing, so "assertion" is the wrong word for what it does.) This is a deliberately different guarantee level from [Corridor Dressing: A Vocabulary, Best-Effort](#corridor-dressing-a-vocabulary-best-effort) above: a corridor's *length* staying under the nominal cap is still explicitly best-effort, but a corridor *existing at all* between every room and the exit is now a hard invariant, not a probabilistic one. `connectLoops`' shortcuts are held to the same line — they only ever add edges, so they cannot weaken the invariant, and `assertAllRoomsReachable` still runs after them. `[notes: sometimes rooms without exit are generated item]` ## Persistence & Storage Limits diff --git a/src/main.ts b/src/main.ts index 131619ef..810e8493 100644 --- a/src/main.ts +++ b/src/main.ts @@ -324,8 +324,12 @@ const multiplayerGuestLiveCount = requireElement("#multipl const multiplayerLobbyDialog = requireElement("#multiplayer-lobby-dialog"); const multiplayerLobbyList = requireElement("#multiplayer-lobby-list"); const closeMultiplayerLobbyButton = requireElement("#close-multiplayer-lobby"); -// --- Launch method tabs (Local / Continue / GitHub / Demo level) ----------- -// Select Workspace, Continue Run, Load from GitHub, and the bundled demo +// --- Launch method tabs (Local / Continue / Repo / Demos) ------------------ +// The third tab is labelled **Repo** and takes GitHub, GitLab or Codeberg. Its +// elements were renamed to match (`#tab-repo`, `#tab-panel-repo`) but its +// `LaunchTab` key is still `github`, so that one mismatch below is a leftover +// from when it was GitHub-only rather than a typo. +// Select Workspace, Continue Run, Load from a repository, and the bundled demo // campaign are four different ways to start the same game loop; grouped into // tabs so only one is shown at a time instead of stacking all four // permanently in the sidebar. The Settings tab is the one member that does diff --git a/src/map/generation/enemies.ts b/src/map/generation/enemies.ts index 9f7ef79b..ebe9327a 100644 --- a/src/map/generation/enemies.ts +++ b/src/map/generation/enemies.ts @@ -410,9 +410,10 @@ export function spawnEnemies( // per Elite room" true for every report that counts the archetype. elite: elite && index === 0, // A skirmisher keeps its peers' complexity-derived HP rather than the - // corridor 10-15 roll. Three reasons: that roll *draws rng*, which - // would break the zero-extra-draw property this rule is built on; the - // room's total must stay exactly `25 * complexity` so density stays the + // corridor `EDGE_CASE_HP_MIN`..`EDGE_CASE_HP_MAX` roll. Three reasons: + // that roll *draws rng*, which would break the zero-extra-draw property + // this rule is built on; the room's total must stay exactly + // `HP_PER_COMPLEXITY * complexity` so density stays the // only difficulty lever; and a thinner skirmisher is counter-intuitively // *worse* for neutrality, since it dies sooner and removes more of the // room's damage than a uniform one does. diff --git a/src/map/generation/lore.ts b/src/map/generation/lore.ts index fada654b..82fe496d 100644 --- a/src/map/generation/lore.ts +++ b/src/map/generation/lore.ts @@ -19,7 +19,7 @@ import { dist, key, neighbors, shuffle } from "./util"; const MAX_LORE_TERMINALS = 6; /** Flat HP for a TODO/FIXME "Bug" enemy — well under `HP_PER_COMPLEXITY` - * (25, the existing floor for a normal complexity-scaled enemy from + * (35, the existing floor for a normal complexity-scaled enemy from * `spawnEnemies`), so it reads as a minor nuisance, not a real fight. */ const TODO_BUG_HP = 10; diff --git a/src/map/mapGenerator.ts b/src/map/mapGenerator.ts index 7d7ac539..ef891b12 100644 --- a/src/map/mapGenerator.ts +++ b/src/map/mapGenerator.ts @@ -260,8 +260,12 @@ export class MapGenerator { // this draws from) can steer clear of one, the same reasoning as the exit. const multiplayerSpawns = maxPlayers > 1 ? pickMultiplayerSpawns(rooms, exit, maxPlayers) : undefined; const enemies = spawnEnemies(rooms, exit, rng, multiplayerSpawns ?? []); - // "Edge Case" enemies populate the corridor features exclusively — never - // a normal room, and normal enemies never spawn in one. + // `spawnEdgeCaseEnemies` populates the corridor features exclusively, and + // is the only thing that populates them. Note the converse does *not* + // hold: the `edgeCase` flag is set in two other places as well + // (`placeSwitchboardEncounters`, and `spawnEnemies`' skirmisher swap), so + // it reads as *which weapon and behaviour this enemy has*, never as + // *where it came from* — see `Enemy.edgeCase` in `types.ts`. enemies.push(...spawnEdgeCaseEnemies(grid, breakupRooms, exit, rng)); // Switchboard spokes get their minor encounter here, for the same reason // Edge Cases do: `spawn`/`exit` are final, `clearCriticalTiles` hasn't run diff --git a/vitest.config.ts b/vitest.config.ts index e53b410d..d830c333 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -98,9 +98,19 @@ export default defineConfig({ // and vocabulary.ts genuinely run but are still reported as uncovered. // Not fixed by disabling file-parallelism, nor by switching to the // `istanbul` coverage provider (which disagrees with `v8` on a - // different set of lines instead). These thresholds sit just below - // the current honestly-measured 99.94/99.79/99.62/99.97% so real - // regressions still fail the gate. + // different set of lines instead). The thresholds were set just below + // the then-measured 99.94/99.79/99.62/99.97% (statements/branches/ + // functions/lines) so real regressions still fail the gate. + // + // **Re-measured 2026-08-26 on master: 99.9083/99.5225/99.84/99.99%, and + // the margin is now thin enough to be worth stating in units rather than + // percentages.** Statements stand at 13,073 of 13,085 and branches at + // 5,627 of 5,654 — so each can afford exactly **one** more uncovered + // unit; the second one fails the gate. That is the intended strictness + // rather than a defect, but it means a change adding two untested + // branches is rejected by coverage before review ever sees it. Update + // these figures when they move; a stale "we measure X" comment is how a + // gate stops being understood. thresholds: { lines: 99.9, statements: 99.9,