e0c1e0d5c6
ci / verify (push) Successful in 49s
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>
155 lines
5.4 KiB
GDScript
155 lines
5.4 KiB
GDScript
extends GutTest
|
|
## The pool is the hottest data structure in the game and the one place a leak
|
|
## would end the run, so its slot bookkeeping is tested directly.
|
|
|
|
var pool: BulletPool
|
|
var map: MapGrid
|
|
|
|
|
|
func before_each() -> void:
|
|
pool = BulletPool.new()
|
|
# Bullets now die against real geometry rather than a global rectangle, so
|
|
# the pool needs a map to cull against at all.
|
|
map = MapGrid.new(42, 24, MapGrid.Kind.WALL)
|
|
map.fill_rect(Rect2i(1, 1, 40, 22), MapGrid.Kind.FLOOR)
|
|
map.centre_on_origin()
|
|
pool.map = map
|
|
|
|
|
|
func _spawn(p := Vector2.ZERO, v := Vector2(100, 0), life := 60) -> int:
|
|
return pool.spawn(p, v, 5.0, life, 10, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
|
|
|
|
|
func test_spawn_assigns_unique_ids() -> void:
|
|
var a := _spawn()
|
|
var b := _spawn()
|
|
assert_ne(pool.uid[a], pool.uid[b])
|
|
assert_eq(pool.live_count, 2)
|
|
|
|
|
|
func test_despawned_slots_are_reused() -> void:
|
|
var a := _spawn()
|
|
pool.despawn(a)
|
|
assert_eq(pool.live_count, 0)
|
|
var b := _spawn()
|
|
assert_eq(b, a, "the free list should hand back the slot just released")
|
|
assert_eq(pool.high_water, 1, "reuse must not grow the scanned range")
|
|
|
|
|
|
func test_double_despawn_does_not_corrupt_the_count() -> void:
|
|
var a := _spawn()
|
|
pool.despawn(a)
|
|
pool.despawn(a)
|
|
assert_eq(pool.live_count, 0)
|
|
|
|
|
|
func test_step_integrates_velocity() -> void:
|
|
var a := _spawn(Vector2.ZERO, Vector2(600, 0))
|
|
pool.step()
|
|
assert_almost_eq(pool.pos[a].x, 600.0 * SimConfig.TICK_DELTA, 0.001)
|
|
|
|
|
|
func test_turn_curves_the_bullet() -> void:
|
|
var a := pool.spawn(Vector2.ZERO, Vector2(100, 0), 5.0, 60, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 0.0, deg_to_rad(90.0))
|
|
pool.step()
|
|
assert_almost_eq(pool.vel[a].angle(), deg_to_rad(90.0), 0.001)
|
|
|
|
|
|
func test_bullets_expire_at_end_of_life() -> void:
|
|
var a := _spawn(Vector2.ZERO, Vector2.ZERO, 3)
|
|
for _i in 3:
|
|
pool.step()
|
|
assert_eq(pool.alive[a], 0)
|
|
assert_eq(pool.live_count, 0)
|
|
|
|
|
|
func test_bullets_leaving_the_map_are_culled() -> void:
|
|
var start := Vector2(map.world_rect().end.x, 0.0)
|
|
var a := _spawn(start, Vector2(100000, 0), 600)
|
|
pool.step()
|
|
assert_eq(pool.alive[a], 0, "a bullet past the cull margin must not linger")
|
|
assert_eq(pool.wall_kill_log.size(), 0,
|
|
"leaving the map is derivable from the map size, so it is not announced")
|
|
|
|
|
|
func test_bullets_stopped_by_a_wall_are_logged_for_announcement() -> void:
|
|
# A client is only streamed the map near itself, so it cannot work out that
|
|
# a bullet hit a wall it has never been sent. The server has to say so.
|
|
var a := _spawn(map.tile_centre(2, 12), Vector2(-600.0, 0.0), 600)
|
|
var id: int = pool.uid[a]
|
|
for _i in 10:
|
|
pool.step()
|
|
assert_eq(pool.alive[a], 0)
|
|
assert_true(pool.wall_kill_log.has(id), "wall deaths must be announceable")
|
|
|
|
|
|
## Wall collision samples a position once per tick, so a bullet that covers
|
|
## more than a tile in that tick passes through solid geometry. The ceiling
|
|
## exists because upgrades multiply bullet speed and two Snipers would ask for
|
|
## 2480 u/s -- above the threshold, and silently, since a tunnelling bullet
|
|
## looks like a bullet.
|
|
func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
|
|
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
|
|
assert_lt(SimConfig.MAX_BULLET_SPEED, limit,
|
|
"the speed ceiling itself has to be under a tile per tick")
|
|
var stacked: Array[StringName] = []
|
|
for _i in 6:
|
|
stacked.append(Upgrades.SNIPER)
|
|
assert_lt(PlayerStats.build(stacked).bullet_speed, limit,
|
|
"%d stacked Snipers must not produce a tunnelling bullet" % stacked.size())
|
|
|
|
|
|
func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void:
|
|
var limit := MapGrid.TILE / 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:
|
|
# Accelerating bullets reach their top speed at the end of their life.
|
|
var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA
|
|
assert_lt(top, limit, "an emitter reaches %.0f u/s" % top)
|
|
|
|
|
|
func test_a_pool_with_no_map_does_not_cull() -> void:
|
|
# Standalone pools (used by emitter tests) have no geometry; culling
|
|
# against nothing would silently delete their bullets.
|
|
var bare := BulletPool.new()
|
|
var a := bare.spawn(Vector2(1.0e6, 0.0), Vector2.ZERO, 5.0, 60, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB)
|
|
bare.step()
|
|
assert_eq(bare.alive[a], 1)
|
|
|
|
|
|
func test_pool_saturation_returns_minus_one_rather_than_growing() -> void:
|
|
for _i in SimConfig.MAX_BULLETS:
|
|
_spawn(Vector2.ZERO, Vector2.ZERO, 10000)
|
|
assert_eq(pool.live_count, SimConfig.MAX_BULLETS)
|
|
assert_eq(_spawn(), -1)
|
|
|
|
|
|
func test_advance_slot_matches_repeated_steps() -> void:
|
|
var a := pool.spawn(Vector2.ZERO, Vector2(150, 40), 5.0, 300, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 30.0, 0.02)
|
|
var other := BulletPool.new()
|
|
var b := other.spawn(Vector2.ZERO, Vector2(150, 40), 5.0, 300, 10,
|
|
SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB, 30.0, 0.02)
|
|
pool.advance_slot(a, 12)
|
|
for _i in 12:
|
|
other.step()
|
|
# Client catch-up must land on exactly the server's integration, or bullets
|
|
# would drift a little further out of place with every packet.
|
|
assert_almost_eq(pool.pos[a].x, other.pos[b].x, 0.0001)
|
|
assert_almost_eq(pool.pos[a].y, other.pos[b].y, 0.0001)
|
|
|
|
|
|
func test_find_by_uid_only_matches_live_bullets() -> void:
|
|
var a := _spawn()
|
|
var id: int = pool.uid[a]
|
|
assert_eq(pool.find_by_uid(id), a)
|
|
pool.despawn(a)
|
|
assert_eq(pool.find_by_uid(id), -1)
|