Documentation pass before context compaction
ci / verify (push) Successful in 47s

The roadmap was actively misleading: an earlier stage renumbering left Stage 2's
completed work sitting under a "Stage 3 -- todo" heading, and the inventory
table orphaned with no heading at all. Since that file is the primary handoff
document, a fresh session would have started by re-implementing accounts and
characters. Rewritten.

Captured in full, from the original brief rather than from memory, the two
stages not yet built:

- Stage 3 (inventory and loot): 4 slots, potions rare from trash and guaranteed
  from bosses, world-shared loot, and the player-instanced food item -- with a
  note that two loot visibilities must exist from the start, because proving the
  instanced path works is the food item's entire purpose.
- Stage 4 (upgrades): every upgrade with its exact stated effect, plus the two
  constraints it will collide with -- sniper's 2x bullet speed against the
  tunnelling threshold, and per-player bullet travel against the interest radius
  that test_interest.gd currently derives from static content.

Ten open questions are listed as explicitly do-not-guess, seven of them blocking
Stage 4.

ARCHITECTURE.md still claimed "the arena is a rectangle" and "no tilemap
collision", both untrue since Stage 1. Rewritten around MapGrid, with what the
grid costs (axis-aligned, 32px-quantised, bullets under a tile per tick) rather
than only what it buys.

Added a "verification traps" section to WORKFLOW.md recording six mistakes made
during this work, each of which cost a round trip of reporting something fixed
that was not: verifying the artefact rather than the behaviour, dumping the
wrong channel, measuring a configuration where the bug cannot exist, a test
whose setup silently invalidated it, git checkout reverting real work alongside
a probe, and a pattern edit matching in two files. They are specific enough to
be actionable.

Also documented the diagnostics in CLAUDE.md -- they were undiscoverable -- and
recorded the current verification surface so "everything passes" has a stated
meaning.

206 tests, 15 smoke assertions, both diagnostics pass.
This commit is contained in:
2026-09-04 20:34:45 +02:00
parent d8197885ca
commit ded7bf96d5
4 changed files with 235 additions and 65 deletions
+119 -53
View File
@@ -13,6 +13,22 @@ that may already have an answer.
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
## Verification surface
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` | 206 GUT tests, no SceneTree | ~3s |
| `tools/smoke.sh` | 15 assertions over a real ENet socket: handshake, auth, character creation and persistence, portal, escape, hard kill, polite disconnect | ~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 |
The two diagnostics exist because the smoke test structurally cannot reach what
they cover: bots are poor shots, and a listen server cannot drift its own clock
against itself.
---
## Stage 1 — World and exploration · *done*
@@ -105,50 +121,83 @@ path cannot be covered by the bot smoke test, because bots are poor shots.
## Stage 3 — Inventory and loot · *todo, next*
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
identity layer, which is decided but unbuilt.
Nothing blocks this. The pieces it needs — accounts, characters, a server that
owns per-player state — all exist.
| Feature | Notes |
| --- | --- |
| Identity abstraction | Shaped like Steamworks so it swaps out cleanly: opaque ticket from client → server validates → stable 64-bit account id (a SteamID64 stand-in). Local dev provider persists a generated id in `user://`. See [DECISIONS.md](DECISIONS.md#identity). |
| Character store | Server-side, keyed by account id. Needs a schema and a file format; nothing persistent exists in the project today. |
| Up to 5 characters, random colour each | Colour is a placeholder for a later cosmetic system. |
| Last-played character auto-selected on join | |
| Permadeath → mark inactive, never delete | Archival/troubleshooting. Confirm whether the 5-character cap counts only *active* characters. |
| Death flow: pick another character or create one | Replaces today's "return to hub" button, which currently just revives you. |
| Levels 115, +10 max HP per level | Confirm whether level 1 is 100 HP (→ 240 at cap) or 110. |
| XP from kills, bosses worth much more | First full dungeon should give slightly more than one level. |
### Requirements
Current behaviour to replace: `SimPlayer` has no identity beyond a peer id;
`ServerRuntime.peer_names` is client-supplied and trusted for display only.
| Feature | Decided | Notes |
| --- | --- | --- |
| Inventory, small enough to sit on screen permanently | **4 slots** | May grow later; do not build a paged or scrolling UI for it. |
| Health potions | rare from normal enemies, **guaranteed from bosses** | The only item with an effect for now. |
| World-shared loot | shared between all players in the instance | First to reach it takes it. |
| A unique, useless food item from bosses | **player-instanced now** | Its whole purpose is to exercise the instanced-loot path rather than defer it. Always dropped by bosses, does nothing when used. |
| Dropping items | any item, back into the world, pickable by others | The path that makes shared loot meaningful. |
### What this implies
Two loot *visibilities* have to exist from the start, because the food item is
specifically there to prove the second one works:
- **World-shared:** one entity in the instance, visible to everyone, gone when
anyone picks it up.
- **Player-instanced:** one entity per eligible player, each seeing and taking
only their own. Others must not see it, which makes it an interest-management
question as much as a loot one — see `ACTOR_INTEREST_RADIUS` and how the
snapshot is already encoded per peer.
Server owns all of it: the client sends "I want to pick that up" as intent and
learns the outcome. There is no message that grants an item.
Persistence is an open question — inventories are not in `CharacterStore` yet,
and a potion that vanishes on server restart may or may not matter at this
stage.
---
## Stage 4 — Upgrades · *todo*
## Stage 4 — Upgrades · *todo, blocked on decisions*
| Feature | Notes |
| --- | --- |
| Lobby NPC, 3 random choices per level gained | Confirm whether unclaimed level-ups queue. |
| Every upgrade also grants +5% damage, additive | Displayed on the choice alongside its specific effects. |
| Upgrade list | split shot, glass cannon, spread, sniper, doubleshot (rarer), poison (rare), eraser (legendary) |
| Rarity weights | **Unspecified.** Needs numbers. |
| Damage stacking formula | **Unspecified.** Sniper is explicitly multiplicative; the rest read as additive. Order needs pinning down before any of it is built. |
| "Chosen upgrades" screen | |
**Do not start this without answering the open questions below.** The damage
formula in particular determines the shape of every upgrade.
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`; upgrades turn
it into a per-player computed stat, so `SimWorld._fire_player_shot` grows a
stats block. Several upgrades (split, spread, doubleshot) change how many
bullets spawn per shot, so they belong in the same place.
### The mechanism
---
- An NPC in the hub. Each level gained grants one choice.
- The choice offers **3 random upgrades**.
- **Every upgrade also carries a +5% damage buff, additive** ("adaptively
scaling" in the brief — read as additive, confirm if wrong).
- The choice screen must show that buff **and** all the upgrade's other effects.
- A separate screen lists the upgrades already taken.
| Feature | Notes |
| --- | --- |
| Small always-on-screen inventory | **4 slots** for now, may grow. |
| Health potions: rare from trash, guaranteed from bosses | |
| World-shared loot | Player-instanced loot planned later. |
| A unique, useless food item from bosses | **Player-instanced now**, so the mechanism is exercised rather than deferred. |
| Dropping items so others can pick them up | |
### The upgrades, as specified
| Upgrade | Rarity | Effect |
| --- | --- | --- |
| Split shot | common | Hitting an enemy spawns 2 of the same bullet at a 45° angle behind the enemy. A shot cannot split twice unless the upgrade is taken again. |
| Glass cannon | common | +100% damage, 50% health. |
| Spread | common | Adds 2 side projectiles in a cone. 10% damage. |
| Sniper | common | 2× damage (**multiplicative, not additive**), 0.5× fire rate, 2× bullet speed. |
| Doubleshot | rarer than common | Adds 1 projectile firing parallel to the others. 50% damage. |
| Poison | rare | Each projectile deals an additional 50% of its damage over the next 10 seconds. |
| Eraser | legendary | Shots have a 1% chance to delete a projectile they pass through. |
Upgrades stack — "cannot split twice *unless upgraded again*" says so directly.
### What this implies
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`. It becomes a
per-player computed stat, so `SimWorld._fire_player_shot` grows a stats block.
Split, spread and doubleshot all change how many bullets a shot produces, so
they belong in the same place.
Two constraints already pinned by tests that upgrades will collide with:
- **Sniper doubles bullet speed.** `test_bullet_speeds_stay_below_the_tunnelling_threshold`
asserts that even at 2× a bullet stays under one tile per tick. Stacking two
snipers would break wall collision, so the multiplier needs a ceiling.
- **Longer/faster bullets widen `BULLET_INTEREST_RADIUS`.** `test_interest.gd`
recomputes the floor from live content; upgrades change bullet travel *per
player*, which that test does not currently model.
---
@@ -157,15 +206,19 @@ bullets spawn per shot, so they belong in the same place.
| Feature | State |
| --- | --- |
| Stationary phases | done — every current phase |
| Roaming / chasing within the boss room | todo; needs `SimBoss.room` clamping (see Stage 1 partial) |
| Boss confined to its room | done — `SimWorld._step_boss` clamps to `SimBoss.room` |
| Roaming / chasing within the boss room | todo |
| Phases that move to preset locations | todo |
| Attacks spawned at a distance with a telegraph indicator | todo — new event type plus a renderer, and it must survive fog |
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` yet |
| Attacks spawned at a distance with a telegraph indicator | todo — a new event type plus a renderer, and it must survive fog |
| More bosses | partial — `Rooms.choir_vault()` is authored but has no `BossDef` |
The boss format itself is proven: `tests/unit/test_boss.gd` builds a boss from
scratch and asserts the simulation needs no changes to run it. Movement will be
the first thing that format has not covered, so expect `BossPhase` to gain a
movement field rather than `SimWorld` gaining a branch.
The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and
asserts the simulation needs no changes to run it. Movement is the first thing
that format has not covered, so expect `BossPhase` to gain a movement field
rather than `SimWorld` gaining a per-boss branch.
Remember boss rooms **do not lock** (a settled decision): a player can always
walk out, and the boss cannot follow. Fights cannot rely on trapping anyone.
---
@@ -188,18 +241,31 @@ Not oversights — each was considered and rejected for now, with the reasoning
## Open questions for the user
Genuinely unspecified; do not guess at these, they change the design:
Genuinely unspecified. **Do not guess at these** — each changes the design, and
several have no obvious default.
Deferred to the Stage 4 discussion (upgrades), but they block that stage:
### Blocking Stage 4 (upgrades)
1. Damage stacking order — additive pool then multiplicative, or something else?
2. Rarity weights for the four upgrade tiers.
3. Split shot geometry: ±22.5° from the original heading, or 45° to each side?
4. Poison: do applications stack, or refresh a single DoT?
5. Eraser: does it delete *enemy bullets* it passes through?
6. Do unclaimed level-ups queue at the NPC?
7. Glass cannon's 50% health: of base HP, or of levelled max HP?
1. **Damage stacking order.** Sniper is explicitly multiplicative; the +5% per
upgrade and the ± percentages read as additive. Is it
`base × (1 + Σ additive) × Π multiplicative`, or something else?
2. **Rarity weights** for common / rarer / rare / legendary.
3. **Split shot geometry** — ±22.5° from the original heading (45° total), or
45° to each side (90° total)?
4. **Poison stacking** — do applications stack, or does a new hit refresh one
damage-over-time effect?
5. **Eraser's target** — does it delete *enemy bullets* it passes through?
6. **Do unclaimed level-ups queue?** Reaching level 4 and 5 inside one run:
two pending choices at the NPC, or one?
7. **Glass cannon's 50% health** — of base HP, or of the character's levelled
maximum?
Still open outside Stage 4:
### Blocking nothing yet
8. What advances dungeon depth? `--depth` is a dev flag; nothing raises it in play.
8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it
in play. Depth drives map size and could drive difficulty and rewards.
9. **Do inventories persist?** Characters do. A potion surviving a server
restart may or may not matter at this stage.
10. **Attribution for four asset packs.** See [ASSETS.md](ASSETS.md) — two are
non-redistributable and local-only, and there is no in-game credits screen
yet, which CC BY 4.0 requires for the audio.