diff --git a/CLAUDE.md b/CLAUDE.md index 08d2e93..6eb07bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ the obvious default. ```bash tools/check.sh # parse-check every script (~5s) -- run after every edit tools/test.sh # 576 GUT tests, headless (~4s) -tools/smoke.sh # real server + 4 bot clients over ENet (~40s) +tools/smoke.sh # real server + 5 bot clients over ENet (~40s) tools/server.sh # dedicated server tools/client.sh --listen # host and play, no menu ``` @@ -209,6 +209,14 @@ ticks in milliseconds with no SceneTree. from `_place`.** `SimWorld` knows nothing about characters and builds a blank player on every instance change, so any new path that puts someone in a world must go through `_place` or it hands them a level-1 body with an empty bag. + Current health comes from the record too (`Character.hp`), and is banked by + `_bank_health` wherever a player leaves a world — miss one and that door + becomes a free heal. +- **One account, one session.** `on_hello` refuses a peer whose account is + already playing; two live players on one character duplicate items. +- **Anything that calls `Net.host()` from a tool sets `GameOpts.store_path` and + `GameOpts.account_override` first.** The default is the player's real store and + account; `screenshot.tscn` once played their character into dungeons. - **A player's combat numbers are derived, never stored.** `PlayerStats.build()` recomputes them from the character's upgrade ids every time, so a saved stat cannot disagree with the upgrades that produced it. Upgrade *riders* (split diff --git a/README.md b/README.md index c4427ea..01d87c3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Godot 4.7, GDScript, no external runtime dependencies. ```bash tools/server.sh # dedicated server on :27015 -tools/client.sh --join --name ada # connect a client +tools/client.sh --join # connect a client; you name a character on the roster screen ``` Or run one client and press **Host and play** for a listen server. @@ -25,8 +25,9 @@ hitbox overlay. Every one of those is rebindable in Settings. | **Authoritative multiplayer** | Clients send input and nothing else. Hits, damage, death, loot, upgrades and instance transfers are all server decisions. Prediction and reconciliation for the local player. | | **Enemies** | Five readable movements (static, drift, orbit, approach, strafe) composed with bullet emitters. Nothing deals contact damage; every threat is a bullet you can see. | | **Two bosses** | The Warden of the Fold stands still through four phases. The Cantor of the Vault walks a circuit, chases, orbits, and marks the floor before it strikes. Both are pure data. | -| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand. | -| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. | +| **Three dungeons** | Two real runs, one per boss, plus a Proving Grounds: the same content at a fraction of the health and ten times the drop rate, for testing by hand — and not for release. Generated rooms joined by tunnels two to four tiles wide, which now and then hold a group of enemies. | +| **Fog of war** | Lit by rays, so a shadow cuts straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets fade past the edge of sight, but never so soon that one could reach you unseen. | +| **Characters** | Up to five living per account, permadeath, levels 1–15. Health carries between runs and the hub heals it. Experience is shared, undivided, among the players near enough to have seen the kill. | | **Inventory and loot** | Four slots, always on screen. Loot is either world-shared or instanced per player, and the instanced kind is filtered on the wire rather than hidden in the client. | | **Upgrades** | Seven, drawn three at a time and spent at a hub NPC. Damage, fire rate, extra projectiles, splitting, poison, and a legendary that deletes enemy bullets. | | **Emergency escape** | A one-second channel back to the hub that damage does **not** interrupt — because if it did, quitting the process would be the better escape. Dropping your connection runs the same channel. | @@ -36,8 +37,8 @@ hitbox overlay. Every one of those is rebindable in Settings. ```bash tools/check.sh # parse-check every script ~5s -tools/test.sh # 468 GUT tests, headless ~4s -tools/smoke.sh # server + 4 bot clients over ENet ~40s +tools/test.sh # 576 GUT tests, headless ~4s +tools/smoke.sh # server + 5 bot clients over ENet ~40s ``` Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index aaef875..001323f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -144,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.** | File | Role | | --- | --- | | `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. | +| `src/sim/sim_player.gd`, `sim_enemy.gd`, `sim_boss.gd` | Actor state. A player's current health is restored from its character on arrival, never reset. | +| `src/sim/sim_events.gd` | Every event type. Order is a wire format; `NetCodec.event_payload_size` must know each one's length. | | `src/sim/bullet_pool.gd` | Struct-of-arrays bullet storage and integration. | | `src/sim/input_frame.gd` | The only thing a client may assert about itself. | | `src/sim/player_stats.gd` | Combat numbers derived from a character's upgrades. | @@ -172,6 +174,7 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.** | `src/meta/progression.gd` | XP curve and what a level is worth. Pure functions. | | `src/meta/character.gd`, `character_store.gd` | Characters and their JSON persistence. | | `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. | +| `src/meta/local_auth_provider.gd` | The development provider. **Insecure on purpose** — any client can claim any account. Do not ship it. | | `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. | ### Network @@ -190,13 +193,28 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.** | --- | --- | | `src/view/world_view.gd` | Everything that is not a bullet. Fog, actors, loot, telegraphs. Draw order is the fog's mechanism: everything it may hide, then the darkness, then what it must not. | | `src/view/visibility.gd` | The fog's geometry: rays from the eye, wall corners, the shadow mesh. Pure, so `test_visibility.gd` asks it which points are lit. | -| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. | +| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. Fades bullets past the edge of sight, never sooner than `WARNING_TIME` before one could land (`visibility()`). | | `src/view/art.gd` | Every atlas rect and sound path, in one table. | | `src/view/ui_theme.gd` | The control theme, built in code from the UI pack. | | `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. | | `src/view/game_scene.gd`, `sfx.gd` | Wiring the view to whatever client `Net` currently has. | -| `src/ui/hud.gd` | Bars, inventory, prompts. Drawn, not built from controls. | +| `src/ui/hud.gd` | Player panel, party, boss bar, banners, message feed, inventory, prompts. Drawn, not built from controls; stacked by a running cursor so nothing collides. | | `src/ui/main_menu.gd`, `game_menu.gd` | Connect screen and the in-game menu. | +| `src/ui/menu_backdrop.gd` | The real boss patterns playing behind the connect screen, primed so the first frame is already full. | | `src/ui/character_select.gd` | Roster screen: pick or create. | | `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. | | `src/ui/settings_screen.gd`, `credits_screen.gd` | Controls, volumes, attribution. | + +### Tools + +| File | Role | +| --- | --- | +| `tools/check.sh`, `check_scripts.gd` | Parse and type-check every script; refreshes the class cache first. | +| `tools/test.sh` | The GUT suite, headless. | +| `tools/smoke.sh` | A real server and five bots over ENet, asserting the whole loop. | +| `tools/server.sh`, `client.sh` | Run a dedicated server, or a client. | +| `tools/install-hooks.sh` | Points git at `.githooks/`: check and test on commit, smoke on push. | +| `tools/diag_*.gd` | Scene-based end-to-end checks the bots cannot reach: prediction, progression, loot, upgrades. | +| `tools/bench.gd` | Simulation cost per tick. | +| `tools/screenshot.gd` | Every screen, staged. Needs a display. **Its own store and account.** | +| `tools/export_content.gd`, `setup_input_map.gd` | Content to `.tres` for the inspector; the input map, generated correctly. | diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a2ed327..c38d109 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -543,3 +543,95 @@ a different fight in a different room. **A CHASE boss has a dead band around its preferred distance.** The correction is signed, so without one the sign flips every tick at the standoff and the boss vibrates on the spot at the tick rate — a couple of pixels, and unmistakable. + +--- + +## The anti-cheat audit, and the rules it left behind + +*(User asked for an audit for design flaws and cheats, then "fix these".)* Each +of these was found by reading the server's code paths and then proving the +problem with a probe before fixing it. + +**One account, one session — and the newcomer is refused, not the incumbent.** +Two peers on one account each got a player built from the same character record, +so both held a copy of one bag and whichever wrote last won: drop the one potion +from both copies and two potions hit the floor. Refusing the second connection is +the only fix that closes it. The *new* peer is the one turned away because, with +an auth provider anyone can spoof, "kick whoever holds this account" would let +anyone disconnect anyone. A dropped player's linkdead body still holds the +account until its escape channel resolves, which is deliberate: reconnecting +during that second would put the character in the world twice. + +**A rejection must reach the client.** `Net.reject_and_kick` defers the kick, +because an RPC only leaves on the frame's flush and kicking in the same call threw +the reason away — a protocol mismatch used to arrive as "server closed the +connection". + +**Bullets are never born on the far side of geometry.** A muzzle offset is a +teleport the pool never sees. The player's shot falls back to the shooter's +centre when blocked (six Doubleshots used to put shots through a one-tile wall); +an enemy's shot is dropped (boss curtains were spawning through the arenas' own +pillars and barricades). A telegraphed burst is checked from its marker, not from +the boss. + +**Bullet spawns are culled per bullet, not by a flat radius.** Each spawn event +carries the shooter's exact position, and the flat 2900-unit radius exceeded the +whole depth-1 map, so every firing enemy was located for every player. +`ServerRuntime._bullet_concerns` asks whether *this* bullet can reach ground the +player can see. + +**No packet buys unbounded work.** Selecting a character you already hold is a +no-op; select and create are paced per peer (`CONTROL_COOLDOWN_TICKS`), and the +pacing logs once per burst, not once per packet. Item moves and experience grants +mark the store dirty and it is written once per tick at most. + +**The hub has no escape channel.** Completing it there meant transferring into +the instance you were already in — teleport, full heal, map re-streamed. + +**A player's drop is refused when the floor is full.** The cap evicts the oldest +item, which is right for a dungeon's own drops and was wrong for the hub, where it +let a player destroy whatever someone else had left there longest. + +## Health, experience and a debug dungeon + +**The Proving Grounds is a debugging tool and does not ship.** *(User.)* It pays +full experience for a boss with 8% of its health. Remove it from `Dungeons.ORDER` +and its `P` marker before a release — see the roadmap. + +## Tunnels and what is in them + +**Tunnels are 2 to 4 tiles wide, rolled per leg.** *(User: "a varied width with a +minimum of 2".)* A one-tile corridor in a bullet hell is a shooting gallery. The +width is per leg of each L, not varying along a leg; that was the simpler reading +and the user has not asked for the other. The arena is excluded from every carve — +an L that bent the wrong way used to slit 45% of arenas' west walls open. + +**Now and then a tunnel has a group in it.** *(User: "rarely".)* A 10% chance per +leg of two or three of one kind; about one depth-1 run in three gets one. Never +within aggro range of the entrance, checked per member. + +**Nothing can be watching the entrance when you arrive.** Room 0 was always left +empty for this, but only room 0 — and wider tunnels tripled how often a +neighbouring room could see the entrance (25 to 75 runs in 200). Enemies that +would be awake on arrival are skipped: about one in sixteen. Clearing everything +within aggro range would have removed nearly a third, which is a balance change +the user has not asked for. + +## Interface + +**Every key a prompt names is the one actually bound.** All actions are +rebindable; prompts go through `Settings.short_label()`. + +**The boss bar waits until the boss has been seen.** The server sends the boss +through walls from 800 units, and the bar used to announce it to a player still in +the entrance room. It appears once the fog has lit the boss and stays for the +fight. A judgement call, flagged to the user as reversible. + +**The controls hint fades six seconds into a dungeon** and stays in the hub. Also +a judgement call; one constant (`Hud.HINT_HOLD`). + +**The connect screen asks for no name.** The field it had only named the +characters bots create; people name characters on the roster screen. + +**Getting hit reddens the screen's edge, not the whole screen** — a full-screen +tint dimmed the bullets at the moment you were among them. diff --git a/docs/NETCODE.md b/docs/NETCODE.md index 299d0c2..d8d8701 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -165,7 +165,7 @@ Two consequences fall out of partial knowledge: Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy lives in what the server declines to send: terrain outside the stream radius, -and (next stage) actors outside it. +and actors outside the interest radius (below). ## Actors are scoped per peer @@ -188,13 +188,24 @@ Two rules fall out of it: about a bullet must always be told it died, or it keeps a phantom until the lifetime expires. -Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than -actors, because the failure modes are not symmetric. An enemy appearing at the -edge of sight is cosmetic; a bullet withheld at spawn that later flies into view -is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 + -460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that -from the live content, so adding a faster or longer-lived bullet fails a test -rather than producing bullets that wink into existence. +Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*, +at its speed and acceleration over its remaining life, could reach ground the +player can see (`ServerRuntime._bullet_concerns`). The failure modes are not +symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet +withheld at spawn that later flies into view is invisible damage — so the rule +errs long. + +It used to be a flat radius, `BULLET_INTEREST_RADIUS`, sized for the +longest-travelling shot in the game. That was a leak: every spawn event carries +the shooter's exact position, and at 2900 units the radius exceeded the entire +depth-1 map, so every firing enemy anywhere on the floor was located for every +player — past the actor cull, past the map stream, through the fog. The constant +survives only as a hard ceiling, and `test_interest.gd` checks both: that no real +enemy's bullet is announced across a dungeon, and that the ceiling still covers +the longest shot in the game. + +The same rule decides what is sent to a player arriving mid-fight +(`_live_bullet_events`), which is the moment they have seen least of the map. ## Loot has two visibilities, and one of them is an interest rule @@ -318,9 +329,11 @@ transition, not a combat mechanic. | Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position | | Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses | -The respawn lockout is worth calling out: the HUD disables its button for the -same three seconds, but that is presentation. A client that ignores its own UI -and holds the bit down still waits, because the check lives in `SimWorld`. +The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button — +but note what reaches it. A connected player is unbound on the tick it dies and +sent to the roster screen (a settled decision; see DECISIONS.md), so it never +waits at all. The lockout governs the *linkdead* body, which has nobody to show a +roster to and asks for itself. Beyond that, the wire format itself constrains the cheat surface: the move vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()` @@ -411,6 +424,30 @@ the snapshot stream: `Protocol.VERSION` is checked at handshake and mismatched clients are rejected with a reason, rather than desyncing later. +## What the handshake and the server refuse + +- **One account, one session.** A second connection claiming an account that is + already playing is refused — the newcomer, not the incumbent, or a spoofed + claim would kick anyone off. Two peers on one account each held a copy of one + character's bag, which duplicated items. A linkdead body still holds its + account until its escape channel resolves. +- **The reason arrives.** `Net.reject_and_kick` sends the refusal, then kicks + after `REJECT_LINGER`: an RPC only leaves on the frame's flush, so kicking in + the same call discarded it, and the client's disconnect handler overwrote it + anyway. Both are fixed; smoke asserts a refused client reads its reason. +- **No small packet buys large work.** Character select and create rewrite the + whole store, re-place the player and broadcast a roster, so they are paced per + peer (`ServerRuntime.CONTROL_COOLDOWN_TICKS`), and re-selecting the character + you hold is a no-op. Item moves and experience grants — the two persistence + paths a client can trigger every tick — mark the store dirty, and it is written + once per tick at most (`CharacterStore.save_deferred` / `flush`). +- **The two hot decoders check their bounds.** A short or hostile snapshot + decodes as `valid: false` and is dropped; the snapshot channel is unreliable + with ENet's checksum off, so this is a network event, not only an attack. Event + lengths are declared per type (`NetCodec.event_payload_size`), and an unknown + type stops the stream rather than unaligning everything after it. Both size + tables are pinned to their encoders by tests. + ## Instances One server process hosts the lobby hub plus every concurrent dungeon. An diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 0d86ff6..17d3727 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,6 +23,23 @@ a lobby hub with dungeon runs, and an emergency escape. All of that exists, plus the four stages that followed: characters and permadeath, inventory and loot, upgrades, and a second boss that moves. +Since then, in one long session: + +- **An anti-cheat audit, and its fixes.** Item duplication through two sessions + on one account; shots through walls from muzzle offsets (the player's and every + boss's); every firing enemy located by the flat bullet-interest radius; + unbounded store writes from small packets; the hub's escape channel; hub loot + griefing; unbounded snapshot and event decoders. Each is in DECISIONS.md under + "The anti-cheat audit". +- **Health survives a door**, the hub regenerates it at 10%/s, and experience goes + only to players within `XP_AWARD_RADIUS` of the kill. +- **Fog lit by rays**, so shadows cut through tiles ([visibility.gd](../src/view/visibility.gd)), + and **bullets fade past the edge of sight** without ever arriving unseen. +- **Tunnels 2–4 wide**, an occasional enemy group in them, nothing watching the + entrance on arrival, and arenas that keep their walls. +- **Two interface passes** — see the Interface section of DECISIONS.md. +- **The smoke flake is fixed** (below). + So there is no obvious "next ticket". What follows is the honest list of candidates, none of which the user has chosen — **ask before starting one.** @@ -35,6 +52,18 @@ candidates, none of which the user has chosen — **ask before starting one.** | **Replace the two non-redistributable packs** | Bullet and FX art is local-only and non-commercial, which blocks both a public repo copy and a commercial release. | CC0 or CC BY replacements. See [ASSETS.md](ASSETS.md). | | **Transport encryption** | `ENetMultiplayerPeer` supports DTLS. Required before a public server, not before then. | Follows the auth decision. | +And smaller things raised with the user and not yet answered or chosen: + +| Open item | What is known | +| --- | --- | +| **A boss kill can pay nothing** | Both arenas are wider corner to corner (803 and 630 units) than `XP_AWARD_RADIUS` (506), so a player in the far corner when the boss dies gets no experience for the run. Suggested fix: anyone inside the boss room counts as present for a boss kill. | +| **The hub renders at ~60 fps, a dungeon at ~240** | Measured with the old fog and the new, same scene: not the fog. Cause unknown. | +| **Aggro sees through cracks the fog closes** | `MapGrid.has_line_of_sight` steps diagonally between two tiles that touch only at a corner. The fog treats that as closed, so an enemy can occasionally see and shoot you through a gap you cannot see through. | +| **Two one-tile alcoves in the Warden's hall** | Rows 4 and 8 of `Rooms.warden_hall()` are one character longer than the rest, leaving notches in the east wall. Possibly a typo; asked, not answered. | +| **Arena doors are one tile wide** | Authored in the stamps. Tunnels into them are now at least two. | +| **The Proving Grounds must not ship** | Full experience for an 8%-health boss. Remove it from `Dungeons.ORDER` and its `P` marker before a release. | +| **Hit feedback on enemies** | `Art.IMPACT` is still loaded and never played (the candidate above). | + Each stage below also carries its own **Known gaps** section — those are smaller and more specific than the table above. @@ -60,8 +89,8 @@ What "everything passes" currently means. Numbers move; the shape does not. | Gate | Covers | Runtime | | --- | --- | --- | | `tools/check.sh` | every script parses and type-checks | ~5s | -| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s | -| `tools/smoke.sh` | 23 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s | +| `tools/test.sh` | 576 GUT tests, no SceneTree | ~4s | +| `tools/smoke.sh` | 27 assertions over a real ENet socket: handshake, auth, character creation and persistence, all three dungeons, escape, hard kill, polite disconnect, a duplicate account refused with its reason | ~40s | | `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | | `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s | | `diag_loot.tscn` | drop → snapshot → pick up → persist → use → drop, and both loot visibilities on the wire | ~10s | @@ -69,10 +98,15 @@ What "everything passes" currently means. Numbers move; the shape does not. `tools/screenshot.tscn` is a **manual** check, not a gate: it needs a display, and it is the only thing in the project that can tell you the interface -actually rendered. Run it after touching `src/ui/`. It captures the main menu, -the HUD, the pause menu, settings, credits and the upgrade choice — the last of -those by staging an offer, because earning one would make the shot depend on a -dungeon run. +actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures +the connect screen, the hub standing on a portal, the pause menu, settings, +credits, the upgrade choice, the roster after a death, a dungeon arrival, a +tunnel (with and without F1), a staged boss fight, and the escape banner. It runs +on **its own character store and account** — it used to host on the player's +real ones — and stages what it cannot earn (an offer, a party, a death). +Timings are frame counts and frames run as fast as the display allows, so +anything tied to server ticks is captured by condition instead (see the escape +shot). It has now caught four bugs that every other gate passed: the theme applying to nothing, the test suite rewriting the player's settings, an invisible slider @@ -126,7 +160,8 @@ terrain it does not have. | `FOG_VIEW_RADIUS` | 460 | — | What the player can see. | | `ACTOR_INTEREST_RADIUS` | 800 | fog radius | Enemies beyond it are never sent. Above the fog radius so nothing pops in at the edge of sight. | | `MAP_STREAM_RADIUS` | 900 | fog radius | Client predicts movement and simulates bullets against terrain it cannot see. | -| `BULLET_INTEREST_RADIUS` | 2200 | longest bullet travel + fog radius | A bullet is announced once at spawn. Withhold one that later flies into view and it becomes invisible damage. `test_interest.gd` computes the floor from real content, so a faster bullet fails a test instead. | +| `BULLET_INTEREST_RADIUS` | 2900 | longest bullet travel + fog radius | **A ceiling only.** Spawns are culled per bullet — can *this* one reach visible ground (`ServerRuntime._bullet_concerns`). As a flat cull it exceeded the depth-1 map and located every firing enemy for everyone. | +| `XP_AWARD_RADIUS` | 506 | 1.1 × fog radius | Experience goes to the living players within it of the kill, undivided. | Fog is a *rendering* rule and defends nothing on its own — a modified client draws whatever it holds. The defence is what the server declines to send. diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index c331728..9490596 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -224,6 +224,40 @@ character decoder, which then read a field its encoder never wrote. Both are codecs and both had the same trailing lines. Check the match count when patching by pattern, not by line. +### Slicing between two headings deletes whatever was between them + +Replacing a section by cutting from its heading to "the next big heading" took +**451 lines** of the roadmap with it — every stage section sat between the two — +and it was committed and pushed before anything noticed, because no gate reads +prose. End a slice at the marker that actually closes the section (here the +`---` rule after it), and before committing any doc edit, check +`git diff --numstat` for a deletion count bigger than the edit you meant. + +### A mutation that survives is a test that cannot fail + +Twice in one session a new test passed with its fix reverted. One staged a turret +behind a barricade — a turret's muzzle offset cannot clear its own radius plus a +tile, so the scenario could never have failed. Another computed its sample point +*from* the constant under test, so zeroing the constant moved the sample instead +of failing it. Reverting the fix and watching the test go red is the only way to +know it tests anything. + +### A flaky gate needs its logs kept, not rerun + +The smoke test failed intermittently for weeks, always in the pre-push hook, +which discards logs; every rerun passed and the trail went cold. Pushing with +`KEEP=1 git push` kept the third failure's logs, which named the cause in one +grep (a bot walking through the wrong portal). Keep the evidence first; rerun +second. + +### A tool that hosts a server must not use the player's data + +`screenshot.tscn` hosted a listen server with the default character store and +account — the player's own — and walked their character into dungeons for +pictures. Every diagnostic already set `GameOpts.store_path` and +`GameOpts.account_override`; the screenshot tool had simply never been asked to. +Anything new that calls `Net.host()` sets both first. + ## CI `.github/workflows/ci.yml` runs check → test → smoke on a container image with