Files
claude e0c1e0d5c6
ci / verify (push) Successful in 49s
Stage 5: bosses that move, attacks that warn, and a second boss
Boss 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) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally 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, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00

115 lines
4.8 KiB
GDScript

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:
# The player's shot is the longest-travelling bullet in the game once
# upgrades are involved, so the worst case is measured at the speed ceiling
# rather than at the base speed. test_interest used to model only static
# content, which upgrades quietly invalidated.
var worst := SimConfig.MAX_BULLET_SPEED \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for boss_id in Content.ALL_BOSSES:
for phase in Content.boss(boss_id).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)
## The ceiling is what makes the radius above computable at all. Without it a
## stack of Snipers would push bullet travel past whatever radius was chosen,
## and the failure would be invisible damage rather than a failing test.
func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
var ids: Array[StringName] = []
for _i in 8:
ids.append(Upgrades.SNIPER)
var travel := PlayerStats.build(ids).bullet_speed \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)