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