diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 338665f..8879ccc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -38,457 +38,18 @@ candidates, none of which the user has chosen — **ask before starting one.** Each stage below also carries its own **Known gaps** section — those are smaller and more specific than the table above. -### Known flake - -`tools/smoke.sh` has failed twice, **both times inside the pre-push hook**, and -then passed on every direct run after: four in a row the first time, eight the -second (six in a background loop plus two more, one of them the hook itself). -Neither failure's logs survived, because the hook runs smoke without `KEEP`. -The first suspicion was machine load, but the second failure had nothing else -running. What is different about the hook is its context — stdin is git's -pipe of ref lines, which every bot inherits. Unconfirmed. Push with -`KEEP=1 git push` so the logs of the next one survive. - ---- - -## 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` | 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 | -| `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 | -| `diag_upgrades.tscn` | level → banked choice → refused in a dungeon and away from the NPC → taken → new stats → persisted → **survives every zone change** | ~10s | - -`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. - -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 -track and an invisible scrollbar. - -The four diagnostics exist because the smoke test structurally cannot reach -what they cover: bots are poor shots (so they neither level up, produce drops, -nor walk to the quartermaster), and a listen server cannot drift its own clock -against itself. - ---- - -## Stage 1 — World and exploration · *done* - -| Feature | State | Where | -| --- | --- | --- | -| Tile grid: movement / bullet / sight blocking | done | [src/sim/map_grid.gd](../src/sim/map_grid.gd) | -| Dungeon generation from (seed, depth) | done | [src/sim/map_gen.gd](../src/sim/map_gen.gd) | -| Hand-authored boss arenas and hub | done | [src/content/rooms.gd](../src/content/rooms.gd) | -| Walls, pillars, pits, barricades | done | `MapGrid.Kind` + the three `BLOCKS_*` tables | -| Enemies placed per room at creation | done | `Instance._populate()` | -| HUD: player panel, party health, message feed, low-health edge | done | [src/ui/hud.gd](../src/ui/hud.gd) — stacked by a running cursor, so nothing has a fixed row to collide on | -| Prompts name the keys actually bound | done | `Settings.short_label()` | -| Boss bar waits until the boss has been seen | done | `Hud._draw_boss_bar`, reading the fog's `Visibility` | -| A death is announced on the roster screen | done | `ClientRuntime.last_fallen`, `GameScene.fallen_line` | -| Occasional enemy groups in tunnels | done | `Instance._populate_tunnels()` — about one depth-1 run in three, never within aggro range of the entrance | -| Scrolling camera | done | [src/view/game_scene.gd](../src/view/game_scene.gd) | -| Hard fog of war, lit by rays rather than per tile | done | [src/view/visibility.gd](../src/view/visibility.gd), `WorldView._draw_darkness` | -| Per-peer map streaming (anti map-hack) | done | `ServerRuntime._stream_map` | -| Aggro: range **and** line of sight | done | `SimWorld._aggro_target` | -| Cursor-to-world aiming under a scrolling camera | done | `ClientRuntime.screen_to_world` | -| Boss confined to its room | done | `SimWorld._step_boss` clamps to `SimBoss.room`. A no-op while bosses are stationary — established now so Stage 5's movement cannot quietly break it. | -| Actor interest management | done | `NetCodec.encode_snapshot(world, countdown, for_peer)`; per-peer encode in `ServerRuntime`. Bullet spawns filtered separately, see below. | - -### The invariants that matter most here - -**Maps are streamed per peer, and the generation seed is never sent.** See -[NETCODE.md](NETCODE.md#maps-are-streamed-never-sent). `MAP_STREAM_RADIUS` must -stay wider than `FOG_VIEW_RADIUS`, or the client predicts movement against -terrain it does not have. - -**Three radii, deliberately different, and each has a floor it must respect:** - -| Radius | Value | Must exceed | Why | -| --- | --- | --- | --- | -| `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. | - -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. - ---- - -## Dungeon kinds · *done* - -Three entrances stand side by side in the hub, labelled with the fight each one -ends in. - -| Dungeon | `Dungeons` id | Arena / boss | Enemy HP | Boss HP | Loot chance | -| --- | --- | --- | --- | --- | --- | -| Warden's Descent | `warden_descent` | `warden_hall` → the Warden | ×1 | ×1 | ×1 (trash 8%) | -| The Choir Vault | `choir_vault` | `choir_vault` → the Cantor | ×1 | ×1 | ×1 | -| Proving Grounds | `proving_grounds` | *whichever the seed rolls* | ×0.2 | ×0.08 | ×10 (clamped) | - -**A dungeon names its arena, and the arena decides its boss.** Which boss you -are about to fight is the one thing a player chooses before walking into a -dungeon, so it must not be rolled for them. Only the Proving Grounds leaves -`arena` empty — it exists to reach *a* fight fast, and runs there last a couple -of minutes, so the one you want is one re-entry away. - -The Proving Grounds is a **test harness you can walk into**: same generator, -same rooms, same enemies, but everything dies far faster and drops far more. A -manual pass over loot, the inventory, dropping and all four boss phases takes a -couple of minutes instead of a quarter of an hour, and because it is a portal -rather than a launch flag you can compare it to a real run without restarting -the server. - -**It is a debugging tool and does not ship.** It pays exactly the same -experience a real run does — `Progression.xp_for_boss` ignores its argument and -`xp_for_enemy` keys on the enemy id, which the difficulty multipliers do not -touch — while its boss has 8% of its health and its trash drops ten times as -often. That makes it strictly the best place to level and to farm, which is -correct for a harness and would be ruinous in a release. Remove it from -`Dungeons.ORDER` (and its `P` marker from the lobby stamp) before shipping, or -give `DungeonDef` an `xp_mult` alongside the three multipliers it already has. - -Adding a dungeon is one entry in `Dungeons.ORDER` plus one more `P` marker in -the lobby stamp — the Nth marker, in reading order, opens the Nth entry, so the -two have to stay in the same order. - -Two more things worth knowing: - -- **It is multipliers over the shared content, not a copy of it.** A duplicated - `Content` would drift the moment anything was tuned, and "identical but - easier" would quietly stop being true. -- **Which dungeon you enter comes from where you stand.** `SimWorld.portal_at()` - resolves the server-side position; the `PORTAL_USED` event carries the answer. - No client message names a dungeon, which is what stops anyone picking the - generous loot table from a real run. `accepts_new_party_member` compares - dungeon ids too, so walking into one entrance can never drop you into - another's forming run on timing alone. - ---- - -## Art and audio · *first pass done* - -Placeholders are gone: terrain, actors and bullets are sprites, and four sounds -play off server events. Enough to prove the pipeline, not a finished look. - -| Feature | State | Where | -| --- | --- | --- | -| Atlas/sound table in one place | done | [src/view/art.gd](../src/view/art.gd) | -| Terrain, actors, boss from the 0x72 atlas | done | `WorldView._draw_tile` / `_draw_sprite` | -| Animated bullet sprites, one MultiMesh per kind | done | [src/view/bullet_renderer.gd](../src/view/bullet_renderer.gd) | -| SFX pool driven by server events | done | [src/view/sfx.gd](../src/view/sfx.gd) | -| Rects validated without a display | done | `tests/unit/test_art.gd` | -| Impact/death VFX animation | todo | `Art.IMPACT` is loaded and validated but nothing plays it yet | -| Directional sprites, hit flashes, screen shake | todo | | -| Rebindable controls | done | [src/ui/settings_screen.gd](../src/ui/settings_screen.gd), [src/core/settings.gd](../src/core/settings.gd) | -| Themed controls from a UI pack | done | [src/view/ui_theme.gd](../src/view/ui_theme.gd), art in `assets/sprites/ui/` | -| Dialogs on panels, sliders, scrollbars | done | `UiTheme.dialog_panel`, `HSlider` / `VScrollBar` theme entries | -| Upgrade cards framed by rarity | done | `UiTheme.card_panel`, `UpgradeScreen._make_card` | -| Textured HUD bars and inventory slots | done | `HUD._bar`, `HUD._draw_inventory` | -| Audio buses and a volume setting | done | `Settings`, an SFX bus created at runtime, sliders in the settings screen | -| **In-game credits screen** | **done** | Settings → Credits, from [src/core/credits.gd](../src/core/credits.gd). Two packs are CC BY 4.0 and attribution is a licence *requirement* — [CREDITS.md](../CREDITS.md) is not reachable by a player. | -| Replace the two non-redistributable packs | todo | Bullet and FX art is local-only and non-commercial. CC0 replacements would let them into the repo and unblock a commercial release. See [ASSETS.md](ASSETS.md). | - -## Stage 2 — Characters, persistence, levels · *done* - -| Feature | State | Where | -| --- | --- | --- | -| Steam-shaped identity abstraction | done | [src/meta/auth_provider.gd](../src/meta/auth_provider.gd), [local_auth_provider.gd](../src/meta/local_auth_provider.gd) | -| Character store, JSON, survives restart | done | [src/meta/character_store.gd](../src/meta/character_store.gd) | -| Up to 5 living characters, random colour | done | `CharacterStore.MAX_ACTIVE`, `Character.create` | -| Last-played auto-selected on login | done | `CharacterStore.last_played` | -| Permadeath → retired, never deleted | done | `ServerRuntime._on_player_died` | -| Roster screen: pick or create | done | [src/ui/character_select.gd](../src/ui/character_select.gd) | -| Levels 1–15, +10 max HP each | done | [src/meta/progression.gd](../src/meta/progression.gd) | -| XP from kills, bosses worth far more | done | `ServerRuntime._award_kill` | -| Colour visible in world and on the HUD | done | snapshot carries it; `WorldView._draw_ship` tints | -| Swap character from the hub | done | Esc menu → Change character; refused server-side in a dungeon | -| XP percentage to next level | done | `HUD._draw_xp_bar`, fed live from the snapshot | -| Dead characters hidden from the roster | done | `ServerRuntime._send_characters` sends living only | -| Suggested name when creating | done | `Character.random_name` | -| Passive health regeneration | done | `SimPlayer.regenerate`, 0.5%/s of maximum | - -Verified end to end by `tools/diag_progression.tscn`, which drives the real -server through kill → xp → level → health and death → retire → roster. That -path cannot be covered by the bot smoke test, because bots are poor shots. - -### Still open in this area - -- **The local identity provider is insecure by design.** Any client can claim - any account id. Fine for a LAN; must be replaced before the game is reachable - from the internet. Swapping in Steam is one `AuthProvider` subclass and no - schema change. -- `--account` and `--store` exist so several clients and test runs can coexist - on one machine. A real provider makes `--account` unnecessary. - -## Stage 3 — Inventory and loot · *done* - -| Feature | State | Where | -| --- | --- | --- | -| 4 slots, permanently on screen | done | `SimConfig.INVENTORY_SLOTS`, `HUD._draw_inventory` | -| Items defined as data, not code | done | [src/content/items.gd](../src/content/items.gd), [src/actors/items/item_def.gd](../src/actors/items/item_def.gd) | -| Loot tables on enemies and bosses | done | `EnemyDef.loot` / `BossDef.loot`, rolled in `SimWorld._drop_loot` | -| Health potion, rare from trash | done | `Content.TRASH_POTION_CHANCE` = 0.08 | -| …guaranteed from the boss | done | `Content.warden()` loot table, chance 1.0 | -| World-shared loot | done | `SimLoot.owner_peer == 0` | -| Player-instanced loot | done | one `SimLoot` per living player, filtered per peer in `NetCodec.encode_snapshot` | -| Warden's Ration — useless, instanced | done | `Items.wardens_ration()` | -| Pick up, use, drop | done | `SimWorld._try_pickup` / `_use_slot` / `_drop_slot` | -| Inventories persist | done | stored on `Character`, written by `ServerRuntime._persist_inventory` | -| Ground loot drawn with a pickup prompt | done | `WorldView._draw_loot`, `HUD._draw_pickup_prompt` | - -Controls: **E** picks up, **1–4** use a slot, **shift+1–4** drop one. - -### The decisions worth knowing before touching this - -**Item actions ride the input frame; they are not new messages.** `InputFrame` -gained `BTN_USE`, `BTN_DROP` and a slot byte. That buys the redundancy that -covers a dropped packet, the replay guard on `last_input_tick`, ordering against -movement on the same tick, and a natural rate limit of one action per tick — all -of which a separate reliable RPC would have needed bolted back on. - -**Item actions are edge-triggered; movement and fire are not.** The client -repeats its last few frames every tick and a starved server coasts on the last -one it was given, so a level-triggered read empties the whole inventory in four -ticks. `SimPlayer.prev_buttons` and `prev_slot` hold the edge, and the *slot* is -part of it — tapping 2 while 1 is held is a second, distinct action. - -**Instanced loot is enforced on the wire, not in the client.** A peer is never -told another player's copy exists. That makes it an interest-management rule of -the same kind as `ACTOR_INTEREST_RADIUS`, and it is why the ration is worth -having: every boss kill exercises the path. - -**Anything dropped becomes world-shared, even if it arrived instanced.** That is -what makes dropping worth having — a trophy you do not want should be able to -reach someone who does. - -**A potion at full health is refused rather than spent.** Nobody drinks one on -purpose at full health, so a mistimed keypress must not do it for them. - -**A full bag leaves the item on the floor** and does not block the portal, which -shares the interact key. - -### Known gaps - -- **Nothing sells items.** Loot only comes from kills; the hub has no source. - The Stage 4 upgrade NPC is the natural place, and is the reason this is a gap - rather than a decision. -- **No stacking.** Four potions take four slots. A count byte per slot is cheap - to add; nothing needed it yet, so the wire, the save record and the HUD all - stayed simpler for not having one. -- **Ground loot never expires**, it is only capped at - `SimConfig.MAX_LOOT_PER_INSTANCE` per world, oldest evicted. Dungeons close - and take their litter with them; only the hub can realistically reach the cap. -- **The ration draws as a gold flask.** The tileset has no food sprite. See - [ASSETS.md](ASSETS.md). - ---- - -## The scale of health and damage - -Health and damage are an order of magnitude larger than they started. A shot is -60, a fresh character has 1000, the Warden has 36000. **Every ratio is -unchanged** — enemy health, boss health and every emitter's damage were scaled -together, so time to kill is exactly what it was. - -The reason is rounding. At the old base of 6 damage, the +5% every upgrade -carries computed to 6.3 and rounded straight back to 6, so a player's first -upgrade visibly did nothing. At 60 it is +3, and every percentage in the game -now has somewhere to land. - -`tests/unit/test_content.gd` pins both halves of that: that a single upgrade -changes damage, and that shots-to-kill for each enemy is what it was before. -Do not tidy these numbers back down without scaling `content.gd` with them. - -The hub's practice dummy stopped relying on a huge health pool at the same -time: it is `indestructible` by flag now. The old 100000 was already past the -u16 the snapshot sends enemy health in, and after the rescale a patient player -could have destroyed the hub's only practice target for everyone. - ---- - -## Stage 4 — Upgrades · *done* - -Every level gained banks one choice. Choices are spent at the **quartermaster**, -an NPC in the hub: walk to it, press E, pick one of three. - -| Feature | State | Where | -| --- | --- | --- | -| Upgrades as data, no engine branches | done | [src/content/upgrades.gd](../src/content/upgrades.gd), [upgrade_def.gd](../src/actors/upgrades/upgrade_def.gd) | -| Per-player derived stats | done | [src/sim/player_stats.gd](../src/sim/player_stats.gd) | -| 3 weighted options per choice, held until spent | done | `Upgrades.roll_offer`, stored on `Character.offer` | -| Choices queue across levels | done | `Character.pending_choices` | -| Spent only at the NPC, enforced server-side | done | `SimWorld.at_upgrade_npc`, `ServerRuntime.on_choose_upgrade` | -| Choice screen showing the +5% and each effect | done | [src/ui/upgrade_screen.gd](../src/ui/upgrade_screen.gd) | -| List of upgrades already taken, plus resulting stats | done | lower half of the same panel | -| Upgrades persist, die with the character | done | `Character.upgrades`, permadeath retires the record | - -### The seven upgrades - -| Upgrade | Rarity | Effect | -| --- | --- | --- | -| Split Shot | common | On hitting something, two more of the same shot leave it at ±45°. One charge each; stacks. | -| Glass Cannon | common | +100% damage, ×0.5 levelled max health. | -| Spread | common | +2 shots in a cone, −10% damage. | -| Sniper | common | ×2 damage (multiplicative), ×0.5 fire rate, ×2 bullet speed. | -| Doubleshot | uncommon | +1 parallel shot, −50% damage. | -| Poison | rare | Each hit deals another 50% of its damage over 10s. Doses stack independently. | -| Eraser | legendary | 1% chance per tick to delete an enemy projectile a shot is passing through. | - -Draw weights: common 100, uncommon 45, rare 18, legendary 4. - -### The decisions behind the numbers - -All four settled with the user; the reasoning is in -[DECISIONS.md](DECISIONS.md). - -- **Damage is `base × (1 + Σ additive) × Π multiplicative`.** The flat +5% every - upgrade carries, Spread's −10%, Doubleshot's −50% and Glass Cannon's +100% - pool additively; Sniper multiplies the result, which is why two Snipers is 4× - and not +200%. -- **Glass Cannon halves the LEVELLED maximum**, so the price does not fade to a - rounding error by level 15. Taken twice it multiplies again. -- **Poison doses are independent**, not refreshed. `PoisonTrack` makes that free: - every dose lasts the same number of ticks, so they expire in insertion order, - the pending expiries are a plain FIFO, and the per-tick cost is O(1) however - many are live. -- **Split Shot's children leave at ±45° from the original heading**, born just - past the target so they cannot be resolved against it again on the same tick. - -### Constraints this collided with, and what it cost - -- **Bullet speed now has a ceiling** (`SimConfig.MAX_BULLET_SPEED`, 1500 u/s). - Wall collision samples once per tick, so anything over one tile per tick - (1920 u/s) tunnels. Two Snipers would have asked for 2480. -- **`BULLET_INTEREST_RADIUS` rose to 2900**, because an upgraded player shot is - now the longest-travelling bullet in the game. `test_interest.gd` measures the - worst case from the ceiling rather than from static content, which is what it - used to do and what upgrades quietly invalidated. -- **Poison ticks do not emit `ENEMY_HIT`.** They land many times a second on a - reliable channel; the client learns hp from the snapshot. Death is still - announced, because the experience award is keyed on it. -- **The eraser's bullet-vs-bullet scan costs nothing until someone owns it** — - it runs only for shots carrying a non-zero chance. - -### Known gaps - -- **One panel, not two screens.** The brief asked for a separate screen listing - upgrades taken; it is the lower half of the choice panel instead, because the - two are read together. -- **No respec, and no way to see another player's build.** - ---- - -## Stage 5 — Boss features and new bosses · *done* - -| Feature | State | Where | -| --- | --- | --- | -| Stationary phases | done | every Warden phase | -| Boss confined to its room | done | `SimWorld._step_boss` clamps after moving | -| Roaming / chasing within the boss room | done | `BossPhase.Move.CHASE`, holds a standoff | -| Phases that move to preset locations | done | `BossPhase.Move.WAYPOINTS`, room-relative | -| Orbiting the arena | done | `BossPhase.Move.ORBIT` | -| Attacks spawned at a distance with a telegraph | done | [telegraphed_strike_emitter.gd](../src/sim/patterns/telegraphed_strike_emitter.gd), `SimEvent.Type.TELEGRAPH` | -| A second boss | done | **Cantor of the Vault**, fights in `Rooms.choir_vault()` | - -### Boss movement - -Movement is a property of the **phase**, not of the boss: a fight that stands -still and then starts hunting you is one boss with two phases. Four modes — -`STATIC`, `ORBIT`, `CHASE`, `WAYPOINTS` — all handled generically in -`SimWorld._move_boss`, so adding a boss that moves is still writing data. - -- **Waypoints are fractions of the arena**, not absolute positions, so one phase - works in the Warden's hall and the Choir Vault alike. -- **CHASE holds a distance rather than closing.** A boss standing on top of you - is a boss whose bullets cannot be read. -- **Every mode is speed-clamped in one place.** `ORBIT` computes an absolute - destination and would otherwise snap onto its circle on the first tick. -- **Movement slides against geometry**, so a boss cannot walk through the - pillars its own arena was designed around. -- **The room clamp is now load-bearing.** It was a no-op while every boss stood - still — which is exactly when an invariant is cheapest to establish. Boss - rooms deliberately do not lock, and that is only an escape if the boss cannot - follow you out. - -### Telegraphed strikes - -`TelegraphedStrikeEmitter` marks spots in the arena and fills them a moment -later. The moment in between is the whole feature: a burst that appears at your -feet is a coin flip, the same burst with a second of notice is a question. - -- **The emitter is stateless**, like every other one — they are shared resources - and two bosses of the same kind must not stomp each other. Strike positions - are *derived* from the volley number, so the announcement and the strike - compute the same points with nothing stored between them. -- **`EmitContext` gained an `events` list**, the only thing an emitter can do - besides spawn bullets. -- **Markers are drawn through fog and through walls.** Everything else in the - view is covered by the darkness; a warning you cannot see is not a warning. See - `WorldView._draw_telegraphs`. - -### The Cantor of the Vault - -| Phase | Movement | Idea | -| --- | --- | --- | -| Call to Prayer | static | Teaches the marker, and nothing else. | -| Processional | waypoints, four corners | The safe half of the room keeps moving. | -| Antiphon | chase at 220u | It comes for you; walls punish running straight. | -| Final Cadence | orbit | Movement and telegraphs at once. | - -**The Cantor has its own portal in the hub.** Which boss a run ends with follows -from the arena its dungeon names, not from the seed and certainly not from -`--depth`, which nothing in play raises. -`test_every_boss_has_a_dungeon_that_reaches_it` pins that adding a boss without -giving it a way in is a failing test rather than a boss nobody meets. - -`Content.ALL_ENEMIES` / `ALL_BOSSES` exist because five places were -hand-maintaining their own copy of the content list, and `tools/export_content.gd` -had already gone stale and silently stopped writing the second boss. - -### Known gaps - -- **Nothing tests how the fight *looks*.** The boss was drawn straight from the - newest snapshot with no interpolation — a visible step at 20 Hz — and every - test passed for as long as bosses stood still. There is still no automated - check that a moving actor renders smoothly; the ones added cover the - interpolation call, not the impression. -- **No boss-specific music, intro or death sequence.** A boss dies like an - enemy, only louder. -- **Telegraph markers are one shape.** A circle is the only warning the client - can draw; a lane or a cone would need another event field. -- **`Rooms` has two arenas.** A third boss needs a third stamp, which is where - the generator's "boss arena on the right-hand side" assumption will be tested. - ---- - -## Deliberate omissions - -Not oversights — each was considered and rejected for now, with the reasoning in -[NETCODE.md](NETCODE.md) or [DECISIONS.md](DECISIONS.md): - -- **Lag compensation.** Rewinding to a shooter's view means a player who dodged - still gets hit; wrong trade for this genre. -- **Snapshot delta compression.** Fine at current actor counts. -- **DTLS / encryption.** `ENetMultiplayerPeer` supports it. Required before any - public server, not before then. -- **Pattern-level bullet replication.** A real bandwidth win that couples the - client to emitter behaviour. -- **`MultiplayerSynchronizer` / `MultiplayerSpawner`.** Right tools, wrong shape - for a bullet hell — see [ARCHITECTURE.md](ARCHITECTURE.md). - ---- +### The smoke flake (diagnosed and fixed) + +`tools/smoke.sh` failed intermittently, always first in a pre-push hook, whose +logs were thrown away. With `KEEP=1` the third occurrence was readable: bot1, +whose account picks the Proving Grounds, had gone into the Choir Vault. The +bot wandered for 90 of every 120 ticks in the hub and then walked to its +entrance holding interact the whole way; the three entrances sit in a row, so a +wander that ended near the middle one carried it across the wrong ring with the +key down. Which dungeons opened depended on where a timing-dependent wander +stopped. The bot now walks straight to its own entrance and presses interact +only on it (`ClientRuntime._bot_input`). If smoke fails again, push with +`KEEP=1 git push` so the logs survive. ## Open questions for the user diff --git a/src/net/client_runtime.gd b/src/net/client_runtime.gd index 926cb67..2ed8328 100644 --- a/src/net/client_runtime.gd +++ b/src/net/client_runtime.gd @@ -274,12 +274,25 @@ func _bot_input() -> InputFrame: # Downed bots ask for the hub, same as a player would -- otherwise a bot # that dies mid-run just lies there and the smoke test stalls. return InputFrame.make(input_tick, Vector2.ZERO, aim, InputFrame.BTN_INTERACT) - if instance_kind == Protocol.InstanceKind.LOBBY and _bot_tick % 120 < 30: - buttons |= InputFrame.BTN_INTERACT - # Walk onto a portal instead of orbiting, or interact never lands. - # Which one is picked from the account id, so a smoke run with several - # bots exercises every dungeon rather than only the first. - move = (bot_portal() - predicted_pos).normalized() + if instance_kind == Protocol.InstanceKind.LOBBY: + # Straight to its own entrance, picked from the account id so a smoke + # run with several bots exercises every dungeon -- and interact pressed + # ONLY while standing on that one. + # + # It used to wander for 90 ticks in every 120 and then walk to its + # portal holding interact the whole way. The three entrances sit in a + # row, so a wander that ended near the middle one sent the bot bound for + # the Proving Grounds straight across the Choir Vault's ring with the + # key down, and in it went. Which dungeons opened depended on where a + # timing-dependent wander happened to stop: this was the smoke test's + # long-standing intermittent failure, and it only ever showed up as + # "the proving grounds opened" (or another entrance) going missing. + var target := bot_portal() + move = (target - predicted_pos).normalized() \ + if predicted_pos.distance_to(target) > 6.0 else Vector2.ZERO + var here := portal_here() + if not here.is_empty() and (here["pos"] as Vector2).is_equal_approx(target): + buttons |= InputFrame.BTN_INTERACT var slot := 0 if instance_kind == Protocol.InstanceKind.DUNGEON: # Grab at whatever is underfoot and occasionally drink, so the item