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)