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:
@@ -13,13 +13,25 @@ the obvious default.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/check.sh # parse-check every script (~5s) -- run after every edit
|
tools/check.sh # parse-check every script (~5s) -- run after every edit
|
||||||
tools/test.sh # GUT suite, headless (~2s)
|
tools/test.sh # GUT suite, headless (~3s)
|
||||||
tools/smoke.sh # real server + 2 bot clients over ENet (~35s)
|
tools/smoke.sh # real server + 4 bot clients over ENet (~40s)
|
||||||
tools/bench.gd # godot --headless --path . --script tools/bench.gd
|
|
||||||
tools/server.sh # dedicated server
|
tools/server.sh # dedicated server
|
||||||
tools/client.sh --join --name ada
|
tools/client.sh --listen # host and play, no menu
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Diagnostics. Each runs as a *scene* (they need the `Net` autoload) and exits
|
||||||
|
non-zero on failure, so they gate like tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
godot --headless --path . res://tools/diag_prediction.tscn # prediction gap; injects clock drift
|
||||||
|
godot --headless --path . res://tools/diag_progression.tscn # kill -> xp -> level -> death -> roster
|
||||||
|
godot --headless --path . --script tools/bench.gd # sim cost per tick
|
||||||
|
python3 tools/build_local_assets.py # rebuild the local-only bullet atlas
|
||||||
|
```
|
||||||
|
|
||||||
|
`diag_progression` exists because the bot smoke test cannot cover progression:
|
||||||
|
bots are poor shots and rarely kill anything.
|
||||||
|
|
||||||
Everything after `--` goes to `GameOpts.parse()`:
|
Everything after `--` goes to `GameOpts.parse()`:
|
||||||
|
|
||||||
| Flag | Effect |
|
| Flag | Effect |
|
||||||
|
|||||||
+38
-6
@@ -45,11 +45,32 @@ This project deliberately does not, for four reasons:
|
|||||||
prototyping and wrong for this shape: it would replicate per-bullet state and
|
prototyping and wrong for this shape: it would replicate per-bullet state and
|
||||||
saturate the link. See [NETCODE.md](NETCODE.md).
|
saturate the link. See [NETCODE.md](NETCODE.md).
|
||||||
|
|
||||||
The trade is that there is no physics engine — no arbitrary collision shapes, no
|
The trade is that there is no physics engine, so collision is hand-written.
|
||||||
tilemap collision. The arena is a rectangle and every hit is circle-vs-circle.
|
Since Stage 1 that means a tile grid ([MapGrid](../src/sim/map_grid.gd)):
|
||||||
For a bullet hell that is not a limitation; hitboxes in the genre are circles by
|
circle-vs-tile for actors, a point test for bullets, and Bresenham line of sight
|
||||||
convention anyway. A dungeon with real geometry would need a static collision
|
shared by fog and aggro. Actor-vs-actor hits stay circle-vs-circle, which is the
|
||||||
representation added to `SimWorld`, not a switch back to nodes.
|
genre convention anyway.
|
||||||
|
|
||||||
|
That was the right call rather than a compromise: the same grid answers
|
||||||
|
collision, sight, and per-peer interest management as array lookups. On arbitrary
|
||||||
|
polygons all three become intersection tests, and the fog query in particular
|
||||||
|
stops being cheap enough to run every frame.
|
||||||
|
|
||||||
|
What it costs: geometry is axis-aligned and 32px-quantised, and bullets must
|
||||||
|
travel less than one tile per tick or the point test steps over walls (pinned by
|
||||||
|
`test_bullet_speeds_stay_below_the_tunnelling_threshold`).
|
||||||
|
|
||||||
|
## Where the layers sit now
|
||||||
|
|
||||||
|
Stage 1 and 2 added two things worth knowing before reading any file:
|
||||||
|
|
||||||
|
- **Geometry is per-world.** There is no global arena. `SimWorld.map` is a
|
||||||
|
[MapGrid](../src/sim/map_grid.gd); the hub and every dungeon have their own,
|
||||||
|
and the client holds a *partial* copy streamed to it in chunks.
|
||||||
|
- **`src/meta/` is server-only.** Accounts, characters, levels and experience
|
||||||
|
live there. The simulation reads a player's level and maximum health; it never
|
||||||
|
writes progression. One writer means a level cannot disagree with the
|
||||||
|
experience that earned it.
|
||||||
|
|
||||||
## Tick
|
## Tick
|
||||||
|
|
||||||
@@ -100,4 +121,15 @@ after `--`:
|
|||||||
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
|
| `src/net/client_runtime.gd` | Prediction, reconciliation, interpolation, bot input. |
|
||||||
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
|
| `src/instances/instance.gd` | Lobby hub and dungeon progression. |
|
||||||
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
|
| `src/autoload/net.gd` | ENet lifecycle, RPCs, local loopback for listen servers. |
|
||||||
| `src/view/bullet_renderer.gd` | The whole bullet field in one MultiMesh draw call. |
|
| `src/sim/map_grid.gd` | Tile grid: collision, line of sight, chunked streaming. |
|
||||||
|
| `src/sim/map_gen.gd` | Dungeon generation. `build()` is the only entry point both sides use. |
|
||||||
|
| `src/content/rooms.gd` | Hand-authored stamps: the hub and each boss arena, as text. |
|
||||||
|
| `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. Server-owned. |
|
||||||
|
| `src/meta/auth_provider.gd` | Identity, shaped like Steamworks so it swaps out. |
|
||||||
|
| `src/net/net_codec.gd` | Snapshot / event / input / roster / character / map-chunk codecs. |
|
||||||
|
| `src/net/server_runtime.gd` | Instances, ticking, transfers, interest, progression, map streaming. |
|
||||||
|
| `src/view/bullet_renderer.gd` | The bullet field: one MultiMesh per bullet kind. |
|
||||||
|
| `src/view/art.gd` | Every atlas rect and sound path, in one table. |
|
||||||
|
| `src/view/debug_draw.gd` | F1 overlay: what the simulation collides against. |
|
||||||
|
| `src/ui/character_select.gd` | Roster screen: pick or create. |
|
||||||
|
|||||||
+119
-53
@@ -13,6 +13,22 @@ that may already have an answer.
|
|||||||
|
|
||||||
Legend: **done** · **partial** (works, with a stated gap) · **todo** (not started)
|
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*
|
## 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*
|
## Stage 3 — Inventory and loot · *todo, next*
|
||||||
|
|
||||||
Depends on nothing in Stage 1 except a place to stand. Blocked only on the
|
Nothing blocks this. The pieces it needs — accounts, characters, a server that
|
||||||
identity layer, which is decided but unbuilt.
|
owns per-player state — all exist.
|
||||||
|
|
||||||
| Feature | Notes |
|
### Requirements
|
||||||
| --- | --- |
|
|
||||||
| 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 1–15, +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. |
|
|
||||||
|
|
||||||
Current behaviour to replace: `SimPlayer` has no identity beyond a peer id;
|
| Feature | Decided | Notes |
|
||||||
`ServerRuntime.peer_names` is client-supplied and trusted for display only.
|
| --- | --- | --- |
|
||||||
|
| 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 |
|
**Do not start this without answering the open questions below.** The damage
|
||||||
| --- | --- |
|
formula in particular determines the shape of every upgrade.
|
||||||
| 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 | |
|
|
||||||
|
|
||||||
Damage is currently the constant `SimConfig.PLAYER_BULLET_DAMAGE`; upgrades turn
|
### The mechanism
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
- 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 |
|
### The upgrades, as specified
|
||||||
| --- | --- |
|
|
||||||
| Small always-on-screen inventory | **4 slots** for now, may grow. |
|
| Upgrade | Rarity | Effect |
|
||||||
| Health potions: rare from trash, guaranteed from bosses | |
|
| --- | --- | --- |
|
||||||
| World-shared loot | Player-instanced loot planned later. |
|
| 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. |
|
||||||
| A unique, useless food item from bosses | **Player-instanced now**, so the mechanism is exercised rather than deferred. |
|
| Glass cannon | common | +100% damage, −50% health. |
|
||||||
| Dropping items so others can pick them up | |
|
| 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 |
|
| Feature | State |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Stationary phases | done — every current phase |
|
| 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 |
|
| 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 |
|
| 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` yet |
|
| 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
|
The boss format is proven: `tests/unit/test_boss.gd` builds one from scratch and
|
||||||
scratch and asserts the simulation needs no changes to run it. Movement will be
|
asserts the simulation needs no changes to run it. Movement is the first thing
|
||||||
the first thing that format has not covered, so expect `BossPhase` to gain a
|
that format has not covered, so expect `BossPhase` to gain a movement field
|
||||||
movement field rather than `SimWorld` gaining a branch.
|
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
|
## 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?
|
1. **Damage stacking order.** Sniper is explicitly multiplicative; the +5% per
|
||||||
2. Rarity weights for the four upgrade tiers.
|
upgrade and the ± percentages read as additive. Is it
|
||||||
3. Split shot geometry: ±22.5° from the original heading, or 45° to each side?
|
`base × (1 + Σ additive) × Π multiplicative`, or something else?
|
||||||
4. Poison: do applications stack, or refresh a single DoT?
|
2. **Rarity weights** for common / rarer / rare / legendary.
|
||||||
5. Eraser: does it delete *enemy bullets* it passes through?
|
3. **Split shot geometry** — ±22.5° from the original heading (45° total), or
|
||||||
6. Do unclaimed level-ups queue at the NPC?
|
45° to each side (90° total)?
|
||||||
7. Glass cannon's −50% health: of base HP, or of levelled max HP?
|
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.
|
||||||
|
|||||||
+62
-2
@@ -106,10 +106,16 @@ Runs the simulation with no transport and no rendering and prints milliseconds
|
|||||||
per tick. Current numbers, 4 players, 60s of play:
|
per tick. Current numbers, 4 players, 60s of play:
|
||||||
|
|
||||||
```
|
```
|
||||||
boss fight 0.237 ms/tick peak 352 bullets headroom x70
|
boss fight 0.26 ms/tick peak ~300 bullets headroom x64
|
||||||
trash wave 0.179 ms/tick peak 145 bullets headroom x93
|
trash wave 0.16 ms/tick peak ~82 bullets headroom x103
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note what this does *not* cover: it measures the simulation only, with no
|
||||||
|
transport. Per-peer snapshot encoding was measured separately (95.6us for four
|
||||||
|
filtered snapshots against 24.9us for one shared, or 0.032 ms/tick amortised).
|
||||||
|
Quoting a bench number for something the bench does not exercise is its own
|
||||||
|
version of the trap below.
|
||||||
|
|
||||||
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
|
A 60 Hz tick has a 16.6 ms budget, so one instance uses ~1.4% of one core. That
|
||||||
is the measurement that says a single server process can host dozens of
|
is the measurement that says a single server process can host dozens of
|
||||||
concurrent dungeons, and it took ten seconds to get because the simulation has
|
concurrent dungeons, and it took ten seconds to get because the simulation has
|
||||||
@@ -164,6 +170,60 @@ auto-reload racing against agent file writes is a known source of stale state.
|
|||||||
The honest recommendation: **start with the headless CLI loop, add the MCP when
|
The honest recommendation: **start with the headless CLI loop, add the MCP when
|
||||||
scene inspection becomes the bottleneck.** It is one command to add later.
|
scene inspection becomes the bottleneck.** It is one command to add later.
|
||||||
|
|
||||||
|
## Verification traps, learned the hard way
|
||||||
|
|
||||||
|
Every entry below cost at least one round trip of reporting something as fixed
|
||||||
|
when it was not. They are specific to this setup, and they generalise.
|
||||||
|
|
||||||
|
### Verify the behaviour, not the artefact you produced
|
||||||
|
|
||||||
|
Bullet sprites were reported fixed three times before they were. Each time the
|
||||||
|
*atlas* was checked and found correct. The actual fault was one layer down: a
|
||||||
|
`MultiMeshInstance2D` cannot display a sub-region of a texture, so the renderer
|
||||||
|
was structurally incapable of showing one cell no matter what the atlas held.
|
||||||
|
Checking the input to a broken stage will confirm the input forever.
|
||||||
|
|
||||||
|
Ask what the *player* would see, and find a way to check that.
|
||||||
|
|
||||||
|
### An ASCII dump only proves what you dumped
|
||||||
|
|
||||||
|
The same sprites were "verified" by rendering their alpha channel. The pack
|
||||||
|
animates as a colour shimmer over a fixed silhouette, so identical-looking
|
||||||
|
frames proved nothing at all. The check was real; it was measuring the wrong
|
||||||
|
channel.
|
||||||
|
|
||||||
|
### Measure the configuration that has the bug
|
||||||
|
|
||||||
|
The client-prediction gap was measured at 0.1px on a listen server and reported
|
||||||
|
fixed. A listen server runs both halves in one process on one physics tick, so
|
||||||
|
the quantity that had drifted — two independent clocks — was constant *by
|
||||||
|
construction*. The bug needed two processes to exist at all.
|
||||||
|
|
||||||
|
If a bug is described as appearing "after a while", ask what accumulates, and
|
||||||
|
make sure your harness lets it accumulate.
|
||||||
|
|
||||||
|
### Have the test tell you the setup was valid
|
||||||
|
|
||||||
|
`test_firing_is_reported_even_when_the_bullet_dies_instantly` asserts that no
|
||||||
|
`BULLET_SPAWN` event occurred, purely to prove the interesting condition was
|
||||||
|
actually reached. The first version placed a turret as the victim — and turrets
|
||||||
|
shoot, so the assertion passed on the turret's own bullets while testing
|
||||||
|
nothing. A setup check inside the test caught it.
|
||||||
|
|
||||||
|
### `git checkout` to clean up a probe reverts real work too
|
||||||
|
|
||||||
|
A one-file revert to remove a temporary debug hook also discarded a fix made to
|
||||||
|
the same file earlier in the session, and it had already been verified and
|
||||||
|
reported. Only `git status` showing the file *missing* from the staged set
|
||||||
|
caught it. Prefer editing the probe back out, or stash.
|
||||||
|
|
||||||
|
### A pattern-based edit can match twice
|
||||||
|
|
||||||
|
A `replace()` intended for the snapshot decoder also matched inside the
|
||||||
|
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.
|
||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
|
`.github/workflows/ci.yml` runs check → test → smoke on a container image with
|
||||||
|
|||||||
Reference in New Issue
Block a user