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/ 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,