Audited rather than appended. What had drifted, now corrected: - ROADMAP's opening still described the project before this session: no word of the anti-cheat fixes, 468 tests, 23 smoke assertions, and a screenshot list half its current size. It now summarises the session, lists seven open items raised with the user and not yet decided (the boss-kill experience edge, the hub frame rate, aggro through corner cracks, the Warden-hall alcoves, one-tile arena doors, the Proving Grounds before release, hit feedback), and quotes current numbers. Its radius table still gave BULLET_INTEREST_RADIUS as the bullet cull at 2200; it is a 2900 ceiling over a per-bullet check. - NETCODE described the flat bullet radius as the design, claimed the respawn lockout gates connected players (they are unbound on death), and called actor scoping "next stage". All corrected, and a new section covers what the handshake and server now refuse: duplicate sessions, reasons that now arrive, paced control messages, coalesced store writes, bounds-checked hot decoders. - DECISIONS gains the audit's rules, the Proving Grounds' status, the tunnel and entrance rules, and the interface calls -- each flagged as the user's or as a judgement call. - ARCHITECTURE's file map lacked the actor classes, the dev auth provider, the menu backdrop and any tools section. - README told people to start a client with --name, which now only names bot characters, and its feature list predated fog, tunnels and carried health. CLAUDE.md gains three invariants (health on the record, one session per account, tools that host use scratch data); WORKFLOW gains the four traps this session fell into or nearly did. Every doc was checked for unintended deletions against the session's start, and every relative link resolves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94ad6b3429
commit
08f58af1a8
7 files changed
+251
-26
No files matched your search
@@ -14,7 +14,7 @@ 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 # 576 GUT tests, headless (~4s)
|
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/server.sh # dedicated server
|
||||||
tools/client.sh --listen # host and play, no menu
|
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
|
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
|
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.
|
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()`
|
- **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
|
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
|
cannot disagree with the upgrades that produced it. Upgrade *riders* (split
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Godot 4.7, GDScript, no external runtime dependencies.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
tools/server.sh # dedicated server on :27015
|
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.
|
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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
||||||
| **Characters** | Up to five living per account, permadeath, levels 1–15, experience shared across the party undivided. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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
|
```bash
|
||||||
tools/check.sh # parse-check every script ~5s
|
tools/check.sh # parse-check every script ~5s
|
||||||
tools/test.sh # 468 GUT tests, headless ~4s
|
tools/test.sh # 576 GUT tests, headless ~4s
|
||||||
tools/smoke.sh # server + 4 bot clients over ENet ~40s
|
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
|
Read [CLAUDE.md](CLAUDE.md) first — it is short, and the Godot CLI gotchas in it
|
||||||
|
|||||||
+20
-2
@@ -144,6 +144,8 @@ nothing from `net`, `view` or `ui`, and never touches `Net`.**
|
|||||||
| File | Role |
|
| File | Role |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `src/sim/sim_world.gd` | The simulation. The authority flag decides what runs. |
|
| `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/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/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. |
|
| `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/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/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/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. |
|
| `src/instances/instance.gd` | The hub and one dungeon run: a world plus a peer list. |
|
||||||
|
|
||||||
### Network
|
### 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/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/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/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/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/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/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/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/character_select.gd` | Roster screen: pick or create. |
|
||||||
| `src/ui/upgrade_screen.gd` | The quartermaster's three choices, and what you hold. |
|
| `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. |
|
| `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. |
|
||||||
@@ -543,3 +543,95 @@ a different fight in a different room.
|
|||||||
**A CHASE boss has a dead band around its preferred distance.** The correction
|
**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
|
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.
|
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.
|
||||||
+48
-11
@@ -165,7 +165,7 @@ Two consequences fall out of partial knowledge:
|
|||||||
|
|
||||||
Hard fog is therefore a *rendering* rule, not a secrecy mechanism. The secrecy
|
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,
|
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
|
## 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
|
about a bullet must always be told it died, or it keeps a phantom until the
|
||||||
lifetime expires.
|
lifetime expires.
|
||||||
|
|
||||||
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than
|
Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
|
||||||
actors, because the failure modes are not symmetric. An enemy appearing at the
|
at its speed and acceleration over its remaining life, could reach ground the
|
||||||
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view
|
player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
|
||||||
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 +
|
symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
|
||||||
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that
|
withheld at spawn that later flies into view is invisible damage — so the rule
|
||||||
from the live content, so adding a faster or longer-lived bullet fails a test
|
errs long.
|
||||||
rather than producing bullets that wink into existence.
|
|
||||||
|
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
|
## 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 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 |
|
| 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
|
The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
|
||||||
same three seconds, but that is presentation. A client that ignores its own UI
|
but note what reaches it. A connected player is unbound on the tick it dies and
|
||||||
and holds the bit down still waits, because the check lives in `SimWorld`.
|
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
|
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()`
|
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
|
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected
|
||||||
with a reason, rather than desyncing later.
|
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
|
## Instances
|
||||||
|
|
||||||
One server process hosts the lobby hub plus every concurrent dungeon. An
|
One server process hosts the lobby hub plus every concurrent dungeon. An
|
||||||
|
|||||||
+42
-7
@@ -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,
|
the four stages that followed: characters and permadeath, inventory and loot,
|
||||||
upgrades, and a second boss that moves.
|
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
|
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.**
|
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). |
|
| **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. |
|
| **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
|
Each stage below also carries its own **Known gaps** section — those are smaller
|
||||||
and more specific than the table above.
|
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 |
|
| Gate | Covers | Runtime |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
| `tools/check.sh` | every script parses and type-checks | ~5s |
|
||||||
| `tools/test.sh` | 468 GUT tests, no SceneTree | ~4s |
|
| `tools/test.sh` | 576 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/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_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s |
|
||||||
| `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~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_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,
|
`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
|
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,
|
actually rendered. Run it after touching `src/ui/` or `src/view/`. It captures
|
||||||
the HUD, the pause menu, settings, credits and the upgrade choice — the last of
|
the connect screen, the hub standing on a portal, the pause menu, settings,
|
||||||
those by staging an offer, because earning one would make the shot depend on a
|
credits, the upgrade choice, the roster after a death, a dungeon arrival, a
|
||||||
dungeon run.
|
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
|
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
|
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. |
|
| `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. |
|
| `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. |
|
| `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
|
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.
|
draws whatever it holds. The defence is what the server declines to send.
|
||||||
|
|||||||
@@ -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
|
codecs and both had the same trailing lines. Check the match count when patching
|
||||||
by pattern, not by line.
|
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
|
## 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