Files
transcience/tests/unit/test_bullet_pool.gd
T
claude b351bc2d55
ci / verify (push) Successful in 48s
Stage 4: upgrades, and a quartermaster to spend them at
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:34:58 +02:00

154 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.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:
# 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)