From 7ee6c8e761317faa367868bb0130f4fbab051142 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 3 Sep 2026 23:33:32 +0200 Subject: [PATCH] Close Stage 1: per-peer actor interest, boss room confinement The gap Stage 1 left open: snapshots were encoded once and broadcast to every peer in an instance, so clients were handed enemies the fog then hid. Fog is a rendering rule -- a modified client draws whatever it holds -- so that was no defence at all. Snapshots are now encoded per peer, and actors beyond ACTOR_INTEREST_RADIUS (800u) are never sent. Measured cost of four filtered encodes against one shared: 95.6us vs 24.9us, or 0.032 ms/tick amortised over the snapshot interval. Two asymmetries worth keeping: - The observer's own player record is never filtered, however far out the arithmetic puts it. The client reconciles its prediction against that record, so dropping it breaks the player's own movement rather than hiding someone. - Only bullet SPAWNS are filtered, never despawns. A client told about a bullet must always be told it died, or it keeps a phantom. Bullet spawns use a much wider radius (2200u) than actors, deliberately. 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 for the Warden's Collapse snipe -- and test_interest.gd recomputes that from live content, so adding a faster or longer-lived bullet fails a test instead of producing bullets that wink into existence. Also clamped bosses to their room. A no-op today since every boss is stationary, which is exactly when the invariant is cheap to establish: boss rooms have no door that locks, so the only thing keeping a fight in the boss room is the boss, and Stage 5's movement work would otherwise break it quietly. 146 tests (was 137). check.sh, test.sh and smoke.sh pass. Stage 1 has no partials left; docs/ROADMAP.md now records the three interest radii and the floor each must respect. --- docs/NETCODE.md | 29 ++++++++ docs/ROADMAP.md | 20 ++++-- src/core/sim_config.gd | 20 ++++++ src/net/net_codec.gd | 35 ++++++++-- src/net/server_runtime.gd | 54 ++++++++++++-- src/sim/sim_world.gd | 10 +++ tests/integration/test_dungeon_flow.gd | 16 +++++ tests/unit/test_interest.gd | 97 ++++++++++++++++++++++++++ tests/unit/test_interest.gd.uid | 1 + 9 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_interest.gd create mode 100644 tests/unit/test_interest.gd.uid diff --git a/docs/NETCODE.md b/docs/NETCODE.md index f1e3186..c9eea7d 100644 --- a/docs/NETCODE.md +++ b/docs/NETCODE.md @@ -158,6 +158,35 @@ 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. +## Actors are scoped per peer + +Snapshots are encoded **once per peer**, not once and broadcast: an actor +further than `ACTOR_INTEREST_RADIUS` (800u) from a player is never sent to that +player. Before this, fog was hiding enemies the client had already been handed, +which is no defence at all against a modified client. + +Measured cost of encoding four filtered snapshots instead of one shared: +95.6µs against 24.9µs, which amortised over the 3-tick snapshot interval is +0.032 ms/tick — 0.2% of the frame budget. + +Two rules fall out of it: + +- **The observer's own player record is never filtered**, however far outside + the radius the arithmetic puts it. The client reconciles its prediction + against that record; dropping it would break the player's own movement rather + than merely hide someone. +- **Bullet despawns are not filtered, only spawns.** A client that was told + 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. + ## No contact damage Nothing hurts you by touching it. Every threat is a bullet you can see and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index abb25e3..609cdaa 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -29,16 +29,28 @@ Legend: **done** · **partial** (works, with a stated gap) · **todo** (not star | 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 | **partial** | `SimBoss.room` exists and bounds curtain patterns, but nothing clamps the boss — every boss is stationary today. Enforcement belongs with boss movement in Stage 5. | -| **Actor interest management** | **todo** | The known gap. `ServerRuntime._physics_process` still encodes one snapshot and sends it to every peer in the instance, so clients are told about enemies the fog then hides. Terrain is properly withheld; actors are not. **Do this first.** | +| 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 invariant that matters most here +### The invariants that matter most here -Maps are streamed per peer, and the generation seed is **never** sent. See +**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. + --- ## Stage 2 — Characters, persistence, levels · *todo, next* diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 000eda4..9c63d4e 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -117,6 +117,26 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds ## always holds the terrain it is about to reveal. const FOG_VIEW_RADIUS := 460.0 +# --- Interest management ---------------------------------------------------- +## Actors further than this from a player are not sent to that player at all. +## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of +## sight, but far short of a dungeon, so a modified client cannot read enemy +## positions across the map. +const ACTOR_INTEREST_RADIUS := 800.0 + +## Bullet spawns are filtered far more generously than actors, and deliberately +## so. A bullet is announced once, at spawn, and then simulated locally -- if we +## withhold it and it later flies into view, it is invisible damage, which is a +## gameplay bug rather than a cosmetic one. An enemy appearing at the edge of +## sight is neither. +## +## The floor is (longest bullet travel + FOG_VIEW_RADIUS): a bullet spawned +## further away than that cannot reach visible ground before it expires. +## test_interest.gd computes that from the real content and asserts this covers +## it, so adding a faster or longer-lived bullet fails a test instead of +## producing bullets that wink into existence. +const BULLET_INTEREST_RADIUS := 2200.0 + # --- Map streaming ---------------------------------------------------------- ## How far around a player the server streams map tiles. Comfortably wider than ## the viewport so movement prediction and bullet simulation always run against diff --git a/src/net/net_codec.gd b/src/net/net_codec.gd index a4bb5b0..ed31997 100644 --- a/src/net/net_codec.gd +++ b/src/net/net_codec.gd @@ -10,15 +10,36 @@ extends RefCounted ## [param cleared_countdown] is whole seconds until a cleared dungeon returns ## its party, or Protocol.COUNTDOWN_NONE when that does not apply. +## [param for_peer] scopes the snapshot to what that player may know: actors +## beyond SimConfig.ACTOR_INTEREST_RADIUS are omitted entirely, so fog is not +## the only thing hiding them. Pass 0 to encode the whole world (tests, and the +## safe fallback if the observer cannot be found). static func encode_snapshot(world: SimWorld, - cleared_countdown: int = Protocol.COUNTDOWN_NONE) -> PackedByteArray: + cleared_countdown: int = Protocol.COUNTDOWN_NONE, + for_peer: int = 0) -> PackedByteArray: var b := StreamPeerBuffer.new() b.big_endian = false b.put_u32(world.tick) b.put_u8(clampi(cleared_countdown, 0, Protocol.COUNTDOWN_NONE)) - b.put_u8(mini(world.players.size(), 255)) + var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null + var eye := Vector2.ZERO + var cull_sq := 0.0 + if observer != null: + eye = observer.pos + cull_sq = SimConfig.ACTOR_INTEREST_RADIUS * SimConfig.ACTOR_INTEREST_RADIUS + + var visible_players: Array[SimPlayer] = [] for p in world.players.values(): + # Always include the observer's own player, whatever the distance + # arithmetic says: the client reconciles its prediction against this + # record, and dropping it would break its own movement. + if observer == null or p.peer_id == for_peer \ + or eye.distance_squared_to(p.pos) <= cull_sq: + visible_players.append(p) + + b.put_u8(mini(visible_players.size(), 255)) + for p in visible_players: b.put_u32(p.peer_id) b.put_float(p.pos.x) b.put_float(p.pos.y) @@ -43,8 +64,11 @@ static func encode_snapshot(world: SimWorld, var live_enemies: Array[SimEnemy] = [] for e in world.enemies.values(): - if e.alive: - live_enemies.append(e) + if not e.alive: + continue + if observer != null and eye.distance_squared_to(e.pos) > cull_sq: + continue + live_enemies.append(e) b.put_u16(mini(live_enemies.size(), 65535)) for e in live_enemies: b.put_u32(e.id) @@ -57,6 +81,9 @@ static func encode_snapshot(world: SimWorld, b.put_u8(clampi(e.def.visual, 0, 255)) var has_boss := world.boss != null and world.boss.alive + if has_boss and observer != null \ + and eye.distance_squared_to(world.boss.pos) > cull_sq: + has_boss = false b.put_u8(1 if has_boss else 0) if has_boss: b.put_u32(world.boss.id) diff --git a/src/net/server_runtime.gd b/src/net/server_runtime.gd index 67dcb71..1e957b1 100644 --- a/src/net/server_runtime.gd +++ b/src/net/server_runtime.gd @@ -41,9 +41,13 @@ func _physics_process(_delta: float) -> void: inst.step() _dispatch_events(inst) if send_snapshot and not inst.peers.is_empty(): - var snap := NetCodec.encode_snapshot(inst.world, inst.exit_countdown_seconds()) + # Encoded per peer, not once and broadcast: each player is told only + # about actors near them. Fog alone was hiding enemies the client + # had already been handed, which is no defence against a modified + # client at all. for peer in inst.peers: - Net.send_snapshot(peer, snap) + Net.send_snapshot(peer, NetCodec.encode_snapshot( + inst.world, inst.exit_countdown_seconds(), peer)) _stream_map(peer, inst) if inst.kind != Protocol.InstanceKind.DUNGEON: continue @@ -83,9 +87,25 @@ func _dispatch_events(inst: Instance) -> void: _: pass - var payload := NetCodec.encode_events(inst.world.tick, events) + # Bullet spawns are scoped per peer too; everything else (hits, deaths, + # phase changes) is low volume and mostly concerns the recipient, so it goes + # to everyone. Despawns are deliberately NOT filtered -- a client that was + # told about a bullet must always be told it died, or it keeps a phantom. + var shared: Array[Dictionary] = [] + var spawns: Array[Dictionary] = [] + for ev in events: + if int(ev["t"]) == SimEvent.Type.BULLET_SPAWN: + spawns.append(ev) + else: + shared.append(ev) + for peer in inst.peers: - Net.send_events(peer, payload) + var for_peer := shared + if not spawns.is_empty(): + for_peer = shared + _spawns_near(inst, peer, spawns) + if for_peer.is_empty(): + continue + Net.send_events(peer, NetCodec.encode_events(inst.world.tick, for_peer)) for peer in to_lobby: _send_to_lobby(peer) @@ -184,11 +204,11 @@ func _place(peer_id: int, inst: Instance) -> void: _stream_map(peer_id, inst) # A player arriving mid-fight has no idea what is already in the air, so # replay the live bullets as spawn events before the next snapshot lands. - var backlog := _live_bullet_events(inst.world) + var backlog := _live_bullet_events(inst.world, peer_id) if not backlog.is_empty(): Net.send_events(peer_id, NetCodec.encode_events(inst.world.tick, backlog)) Net.send_snapshot(peer_id, NetCodec.encode_snapshot(inst.world, - inst.exit_countdown_seconds())) + inst.exit_countdown_seconds(), peer_id)) func _transfer(peer_id: int, to: Instance) -> void: @@ -290,11 +310,31 @@ func _broadcast_roster() -> void: Net.send_roster(peer, payload) -func _live_bullet_events(world: SimWorld) -> Array[Dictionary]: +## Bullet spawns within this peer's interest radius. See +## SimConfig.BULLET_INTEREST_RADIUS for why this radius is much wider than the +## one used for actors. +func _spawns_near(inst: Instance, peer_id: int, spawns: Array[Dictionary]) -> Array[Dictionary]: + var p: SimPlayer = inst.world.players.get(peer_id) + if p == null: + return spawns + var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS var out: Array[Dictionary] = [] + for ev in spawns: + if p.pos.distance_squared_to(ev["pos"]) <= cull_sq: + out.append(ev) + return out + + +func _live_bullet_events(world: SimWorld, for_peer: int = 0) -> Array[Dictionary]: + var out: Array[Dictionary] = [] + var observer: SimPlayer = world.players.get(for_peer) if for_peer != 0 else null + var cull_sq := SimConfig.BULLET_INTEREST_RADIUS * SimConfig.BULLET_INTEREST_RADIUS for i in world.pool.high_water: if world.pool.alive[i] == 0: continue + if observer != null \ + and observer.pos.distance_squared_to(world.pool.pos[i]) > cull_sq: + continue out.append({ "t": SimEvent.Type.BULLET_SPAWN, "uid": world.pool.uid[i], diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 13d5ee9..7abc367 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -334,6 +334,16 @@ func _step_boss() -> void: var phase := boss.current_phase() if phase == null: return + # A boss never leaves its arena. Enforced here rather than left to each + # boss's movement code, because boss rooms deliberately do not lock: the + # player can always walk out, and the fight only stays a fight if the boss + # cannot follow. Currently a no-op (every boss is stationary), which is + # exactly when an invariant is cheapest to establish. + if boss.room.size != Vector2.ZERO: + boss.pos = boss.room.position + Vector2( + clampf(boss.pos.x - boss.room.position.x, 0.0, boss.room.size.x), + clampf(boss.pos.y - boss.room.position.y, 0.0, boss.room.size.y)) + if boss.phase_tick >= phase.telegraph_ticks: var local := posmod(boss.phase_tick - phase.telegraph_ticks, maxi(phase.loop_ticks, 1)) _run_emitters(phase.emitters, boss.pos, local, boss.room) diff --git a/tests/integration/test_dungeon_flow.gd b/tests/integration/test_dungeon_flow.gd index 25af6fb..39e3c64 100644 --- a/tests/integration/test_dungeon_flow.gd +++ b/tests/integration/test_dungeon_flow.gd @@ -29,6 +29,22 @@ func test_the_boss_starts_inside_its_own_room() -> void: "the boss has to start inside the arena it is confined to") +## Boss rooms have no door that locks, so the only thing keeping a boss fight in +## the boss room is the boss itself. Bosses are stationary today, which makes +## this the right moment to pin the invariant -- before movement arrives and +## quietly breaks it. +func test_a_boss_cannot_leave_its_room() -> void: + _step(SimConfig.DUNGEON_FORMING_TICKS + 5) + var boss := inst.world.boss + var room: Rect2 = boss.room + # Shove it far outside, the way a future chase behaviour might. + boss.pos = room.position + room.size + Vector2(2000.0, 2000.0) + inst.step() + assert_true(room.grow(1.0).has_point(boss.pos), + "a boss that can be pushed out of its arena could chase a player who " + + "walked away, and walking away is always allowed") + + func test_nothing_spawns_inside_geometry() -> void: for e in inst.world.enemies.values(): assert_false(inst.world.map.circle_blocked(e.pos, e.def.radius), diff --git a/tests/unit/test_interest.gd b/tests/unit/test_interest.gd new file mode 100644 index 0000000..6c28308 --- /dev/null +++ b/tests/unit/test_interest.gd @@ -0,0 +1,97 @@ +extends GutTest +## Interest management: what the server declines to send. +## +## Fog is a rendering rule and stops nothing — a modified client draws whatever +## it was handed. These tests cover the part that is actually a defence: actors +## and bullet spawns the server never puts on the wire. + +var world: SimWorld +const ME := 1 +const OTHER := 2 + + +func before_each() -> void: + var map := MapGrid.new(200, 60, MapGrid.Kind.WALL) + map.fill_rect(Rect2i(1, 1, 198, 58), MapGrid.Kind.FLOOR) + map.centre_on_origin() + world = SimWorld.new(1) + world.set_map(map) + var me := world.add_player(ME, "me") + me.pos = Vector2.ZERO + + +func _snap_for(peer: int) -> Dictionary: + return NetCodec.decode_snapshot( + NetCodec.encode_snapshot(world, Protocol.COUNTDOWN_NONE, peer)) + + +func test_a_distant_enemy_is_not_sent_at_all() -> void: + world.spawn_enemy(Content.turret(), Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 300.0, 0.0)) + assert_eq((_snap_for(ME)["enemies"] as Array).size(), 0, + "an enemy across the map must never reach the client") + + +func test_a_nearby_enemy_is_sent() -> void: + world.spawn_enemy(Content.turret(), Vector2(200.0, 0.0)) + assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1) + + +func test_enemies_are_visible_well_beyond_what_can_be_seen() -> void: + # The interest radius has to exceed the fog radius, or enemies would appear + # out of nowhere at the exact edge of sight. + assert_gt(SimConfig.ACTOR_INTEREST_RADIUS, SimConfig.FOG_VIEW_RADIUS) + world.spawn_enemy(Content.turret(), Vector2(SimConfig.FOG_VIEW_RADIUS + 50.0, 0.0)) + assert_eq((_snap_for(ME)["enemies"] as Array).size(), 1, + "just out of sight must still be known, so it does not pop in") + + +func test_a_distant_player_is_not_sent() -> void: + var them := world.add_player(OTHER, "them") + them.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 400.0, 0.0) + var peers: Array = _snap_for(ME)["players"] + assert_eq(peers.size(), 1) + assert_eq(int(peers[0]["peer"]), ME) + + +## The one record that can never be filtered: the client reconciles its own +## prediction against it, so dropping it would break the player's own movement +## rather than merely hiding someone. +func test_your_own_player_is_always_sent_however_far_out_you_are() -> void: + world.players[ME].pos = Vector2(1.0e5, 1.0e5) + var peers: Array = _snap_for(ME)["players"] + assert_eq(peers.size(), 1) + assert_eq(int(peers[0]["peer"]), ME) + + +func test_a_distant_boss_is_not_sent() -> void: + var boss := world.spawn_boss(Content.warden()) + boss.pos = Vector2(SimConfig.ACTOR_INTEREST_RADIUS + 500.0, 0.0) + assert_null(_snap_for(ME)["boss"], "you should not see a boss room you are nowhere near") + boss.pos = Vector2(100.0, 0.0) + assert_not_null(_snap_for(ME)["boss"]) + + +func test_omitting_the_observer_encodes_the_whole_world() -> void: + # Peer 0 means "no filtering" — used by tests and as the safe fallback when + # the observer cannot be found. + world.spawn_enemy(Content.turret(), Vector2(9000.0, 0.0)) + var snap := NetCodec.decode_snapshot(NetCodec.encode_snapshot(world)) + assert_eq((snap["enemies"] as Array).size(), 1) + + +## The radius that stops bullets winking into existence, checked against the +## real content rather than guessed. Adding a faster or longer-lived bullet +## should fail here rather than produce invisible damage in play. +func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void: + var worst := SimConfig.PLAYER_BULLET_SPEED \ + * float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA + var emitters: Array[BulletEmitter] = [] + for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]: + emitters.append_array(Content.enemy(id).emitters) + for phase in Content.warden().phases: + emitters.append_array(phase.emitters) + for e in emitters: + worst = maxf(worst, e.speed * float(e.lifetime) * SimConfig.TICK_DELTA) + assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS, + "a bullet spawned just outside the radius must not be able to reach " + + "ground the player can see before it expires (worst travel %.0f)" % worst) diff --git a/tests/unit/test_interest.gd.uid b/tests/unit/test_interest.gd.uid new file mode 100644 index 0000000..0398b18 --- /dev/null +++ b/tests/unit/test_interest.gd.uid @@ -0,0 +1 @@ +uid://c156uloui1reg