Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
2 changes: 1 addition & 1 deletion doc/dev/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,8 +324,12 @@ const multiplayerGuestLiveCount = requireElement<HTMLParagraphElement>("#multipl
const multiplayerLobbyDialog = requireElement<HTMLDialogElement>("#multiplayer-lobby-dialog");
const multiplayerLobbyList = requireElement<HTMLUListElement>("#multiplayer-lobby-list");
const closeMultiplayerLobbyButton = requireElement<HTMLButtonElement>("#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
Expand Down
7 changes: 4 additions & 3 deletions src/map/generation/enemies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/map/generation/lore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
8 changes: 6 additions & 2 deletions src/map/mapGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down