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
+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
|
||||
lives in what the server declines to send: terrain outside the stream radius,
|
||||
and (next stage) actors outside it.
|
||||
and actors outside the interest radius (below).
|
||||
|
||||
## Actors are scoped per peer
|
||||
|
||||
@@ -188,13 +188,24 @@ Two rules fall out of it:
|
||||
about a bullet must always be told it died, or it keeps a phantom until the
|
||||
lifetime expires.
|
||||
|
||||
Bullet spawns use a much wider radius (`BULLET_INTEREST_RADIUS`, 2200u) than
|
||||
actors, because the failure modes are not symmetric. An enemy appearing at the
|
||||
edge of sight is cosmetic; a bullet withheld at spawn that later flies into view
|
||||
is invisible damage. The floor is *longest bullet travel + fog radius* — 1500 +
|
||||
460 for the Warden's "Collapse" snipe — and `test_interest.gd` recomputes that
|
||||
from the live content, so adding a faster or longer-lived bullet fails a test
|
||||
rather than producing bullets that wink into existence.
|
||||
Bullet spawns are filtered **per bullet**: a spawn is sent if *that bullet*,
|
||||
at its speed and acceleration over its remaining life, could reach ground the
|
||||
player can see (`ServerRuntime._bullet_concerns`). The failure modes are not
|
||||
symmetric — an enemy appearing at the edge of sight is cosmetic, a bullet
|
||||
withheld at spawn that later flies into view is invisible damage — so the rule
|
||||
errs long.
|
||||
|
||||
It used to be a flat radius, `BULLET_INTEREST_RADIUS`, sized for the
|
||||
longest-travelling shot in the game. That was a leak: every spawn event carries
|
||||
the shooter's exact position, and at 2900 units the radius exceeded the entire
|
||||
depth-1 map, so every firing enemy anywhere on the floor was located for every
|
||||
player — past the actor cull, past the map stream, through the fog. The constant
|
||||
survives only as a hard ceiling, and `test_interest.gd` checks both: that no real
|
||||
enemy's bullet is announced across a dungeon, and that the ceiling still covers
|
||||
the longest shot in the game.
|
||||
|
||||
The same rule decides what is sent to a player arriving mid-fight
|
||||
(`_live_bullet_events`), which is the moment they have seen least of the map.
|
||||
|
||||
## Loot has two visibilities, and one of them is an interest rule
|
||||
|
||||
@@ -318,9 +329,11 @@ transition, not a combat mechanic.
|
||||
| Upgrade choice made anywhere but the hub NPC | `SimWorld.at_upgrade_npc`, server-side position |
|
||||
| Upgrade index outside the server's own offer | `CharacterStore.take_upgrade` refuses |
|
||||
|
||||
The respawn lockout is worth calling out: the HUD disables its button for the
|
||||
same three seconds, but that is presentation. A client that ignores its own UI
|
||||
and holds the bit down still waits, because the check lives in `SimWorld`.
|
||||
The respawn lockout lives in `SimWorld`, not only in the HUD's disabled button —
|
||||
but note what reaches it. A connected player is unbound on the tick it dies and
|
||||
sent to the roster screen (a settled decision; see DECISIONS.md), so it never
|
||||
waits at all. The lockout governs the *linkdead* body, which has nobody to show a
|
||||
roster to and asks for itself.
|
||||
|
||||
Beyond that, the wire format itself constrains the cheat surface: the move
|
||||
vector is two signed bytes at 1/100 precision, and `Movement.sanitize_move()`
|
||||
@@ -411,6 +424,30 @@ the snapshot stream:
|
||||
`Protocol.VERSION` is checked at handshake and mismatched clients are rejected
|
||||
with a reason, rather than desyncing later.
|
||||
|
||||
## What the handshake and the server refuse
|
||||
|
||||
- **One account, one session.** A second connection claiming an account that is
|
||||
already playing is refused — the newcomer, not the incumbent, or a spoofed
|
||||
claim would kick anyone off. Two peers on one account each held a copy of one
|
||||
character's bag, which duplicated items. A linkdead body still holds its
|
||||
account until its escape channel resolves.
|
||||
- **The reason arrives.** `Net.reject_and_kick` sends the refusal, then kicks
|
||||
after `REJECT_LINGER`: an RPC only leaves on the frame's flush, so kicking in
|
||||
the same call discarded it, and the client's disconnect handler overwrote it
|
||||
anyway. Both are fixed; smoke asserts a refused client reads its reason.
|
||||
- **No small packet buys large work.** Character select and create rewrite the
|
||||
whole store, re-place the player and broadcast a roster, so they are paced per
|
||||
peer (`ServerRuntime.CONTROL_COOLDOWN_TICKS`), and re-selecting the character
|
||||
you hold is a no-op. Item moves and experience grants — the two persistence
|
||||
paths a client can trigger every tick — mark the store dirty, and it is written
|
||||
once per tick at most (`CharacterStore.save_deferred` / `flush`).
|
||||
- **The two hot decoders check their bounds.** A short or hostile snapshot
|
||||
decodes as `valid: false` and is dropped; the snapshot channel is unreliable
|
||||
with ENet's checksum off, so this is a network event, not only an attack. Event
|
||||
lengths are declared per type (`NetCodec.event_payload_size`), and an unknown
|
||||
type stops the stream rather than unaligning everything after it. Both size
|
||||
tables are pinned to their encoders by tests.
|
||||
|
||||
## Instances
|
||||
|
||||
One server process hosts the lobby hub plus every concurrent dungeon. An
|
||||
|
||||
Reference in new issue
Block a user