extends GutTest ## What upgrades actually do in the simulation: shot geometry, splitting, ## poison and the eraser. ## ## Driven through the real firing and hit-resolution paths rather than by ## calling the effects directly, because the claim being tested is that ## SimWorld needs no per-upgrade branches -- only that PlayerStats reaches it. const ME := 1 var world: SimWorld var me: SimPlayer func before_each() -> void: world = SimWorld.new(11) me = world.add_player(ME, "me") me.pos = Vector2.ZERO me.spawn_grace = 0 func _give(ids: Array) -> void: var typed: Array[StringName] = [] for id in ids: typed.append(id) me.stats = PlayerStats.build(typed) me.recompute_max_hp() ## One trigger pull, aimed along +x. func _fire_once() -> int: me.aim = 0.0 me.fire_cooldown = 0 world.pool.clear() var frames: Array[InputFrame] = [ InputFrame.make(world.tick + 1, Vector2.ZERO, 0.0, InputFrame.BTN_FIRE)] world.queue_input(ME, frames) world.step() return world.pool.live_count func _player_bullets() -> Array[int]: var out: Array[int] = [] for i in world.pool.high_water: if world.pool.alive[i] == 1 and world.pool.team[i] == SimConfig.TEAM_PLAYER: out.append(i) return out # --- Shot geometry ---------------------------------------------------------- func test_a_plain_shot_is_one_bullet() -> void: assert_eq(_fire_once(), 1) func test_spread_adds_two_and_fans_them() -> void: _give([Upgrades.SPREAD]) assert_eq(_fire_once(), 3) var angles: Array[float] = [] for i in _player_bullets(): angles.append(world.pool.vel[i].angle()) angles.sort() assert_almost_eq(angles[1], 0.0, 0.01, "the aimed shot is still dead ahead") assert_almost_eq(angles[0], -deg_to_rad(SimConfig.SPREAD_STEP_DEG), 0.01) assert_almost_eq(angles[2], deg_to_rad(SimConfig.SPREAD_STEP_DEG), 0.01) func test_two_spreads_widen_the_cone_rather_than_doubling_the_middle() -> void: _give([Upgrades.SPREAD, Upgrades.SPREAD]) assert_eq(_fire_once(), 5) var widest := 0.0 for i in _player_bullets(): widest = maxf(widest, absf(world.pool.vel[i].angle())) assert_almost_eq(widest, deg_to_rad(SimConfig.SPREAD_STEP_DEG * 2.0), 0.01) ## Parallel, not fanned: same heading, offset sideways. That is the difference ## between Doubleshot and Spread, and it is entirely in the geometry. func test_doubleshot_adds_a_parallel_projectile() -> void: _give([Upgrades.DOUBLESHOT]) assert_eq(_fire_once(), 2) var offsets: Array[float] = [] for i in _player_bullets(): assert_almost_eq(world.pool.vel[i].angle(), 0.0, 0.01, "parallel means the same heading") offsets.append(world.pool.pos[i].y) assert_gt(absf(offsets[0] - offsets[1]), 1.0, "and a different lane") func test_spread_and_doubleshot_stack_into_one_volley() -> void: _give([Upgrades.SPREAD, Upgrades.DOUBLESHOT]) assert_eq(_fire_once(), 4, "one aimed, two fanned, one parallel") func test_sniper_slows_the_trigger_and_speeds_the_bullet() -> void: _give([Upgrades.SNIPER]) _fire_once() assert_eq(me.fire_cooldown, SimConfig.PLAYER_FIRE_COOLDOWN * 2) for i in _player_bullets(): assert_almost_eq(world.pool.vel[i].length(), SimConfig.PLAYER_BULLET_SPEED * 2.0, 1.0) func test_a_shot_carries_the_shooters_upgrades_onto_the_bullet() -> void: _give([Upgrades.SPLIT_SHOT, Upgrades.POISON, Upgrades.ERASER]) _fire_once() for i in _player_bullets(): assert_eq(world.pool.split[i], 1) assert_gt(world.pool.poison[i], 0.0) assert_gt(world.pool.erase[i], 0.0) ## A bullet keeps what it was fired with. Taking Poison while a shot is in ## flight must not reach back and poison it. func test_a_bullet_in_flight_is_not_changed_by_a_later_upgrade() -> void: _fire_once() var before := _player_bullets() _give([Upgrades.POISON]) for i in before: assert_eq(world.pool.poison[i], 0.0) # --- Split shot ------------------------------------------------------------- func _shoot_at(target: Vector2, split: int, poison: float = 0.0, erase: float = 0.0) -> int: # Placed just short of the target with a crawl, so exactly one tick of # integration puts it inside and hit resolution runs on it. var slot := world.pool.spawn(target - Vector2(1.0, 0.0), Vector2(60.0, 0.0), SimConfig.PLAYER_BULLET_RADIUS, 90, SimConfig.PLAYER_BULLET_DAMAGE, SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT) world.pool.set_mods(slot, split, poison, erase) return slot func test_a_hit_with_a_split_charge_produces_two_children() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 _shoot_at(e.pos, 1) world.step() assert_eq(_player_bullets().size(), 2, "the parent is consumed, two children remain") func test_the_children_leave_at_forty_five_degrees_to_either_side() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 _shoot_at(e.pos, 1) world.step() var angles: Array[float] = [] for i in _player_bullets(): angles.append(rad_to_deg(world.pool.vel[i].angle())) angles.sort() assert_almost_eq(angles[0], -SimConfig.SPLIT_ANGLE_DEG, 0.5) assert_almost_eq(angles[1], SimConfig.SPLIT_ANGLE_DEG, 0.5) ## Children are born past the target. Spawned on top of it they would be ## resolved against it again on the same tick -- a free second hit, and with ## several charges a free chain of them. func test_children_do_not_immediately_hit_the_enemy_they_were_born_on() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 var before := e.hp _shoot_at(e.pos, 1) # Several ticks, not one: children spawned during hit resolution are not # visited again until the NEXT tick, so a single step cannot tell a child # born clear of the enemy from one born inside it. for _i in 8: world.step() assert_eq(before - e.hp, SimConfig.PLAYER_BULLET_DAMAGE, "exactly one hit's worth of damage, not three") func test_a_child_of_a_single_charge_cannot_split_again() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 _shoot_at(e.pos, 1) world.step() for i in _player_bullets(): assert_eq(world.pool.split[i], 0, "the charge was spent by the parent") func test_two_charges_let_a_child_split_once_more() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 _shoot_at(e.pos, 2) world.step() for i in _player_bullets(): assert_eq(world.pool.split[i], 1) func test_a_shot_with_no_charges_does_not_split() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 _shoot_at(e.pos, 0) world.step() assert_eq(_player_bullets().size(), 0) func test_splitting_works_on_the_boss_too() -> void: var boss := world.spawn_boss(Content.warden()) boss.pos = Vector2(150.0, 0.0) _shoot_at(boss.pos, 1) world.step() assert_eq(_player_bullets().size(), 2) # --- Poison ----------------------------------------------------------------- func test_a_poisoned_hit_keeps_damaging_after_the_bullet_is_gone() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 _shoot_at(e.pos, 0, 0.5) world.step() var after_hit := e.hp for _i in SimConfig.POISON_DURATION_TICKS: world.step() assert_lt(e.hp, after_hit, "poison should have kept working") ## The user's decision: stacks are independent, so a second dose adds to the ## first rather than replacing it. func test_doses_stack_rather_than_refreshing() -> void: var one := PoisonTrack.new() one.add(600.0, 0) var two := PoisonTrack.new() two.add(600.0, 0) two.add(600.0, 0) var single := 0 var double := 0 # From tick 0, the way the world drives it: a dose applied on tick T ticks # on T and the DURATION-1 ticks after it. for t in SimConfig.POISON_DURATION_TICKS: single += one.step(t) double += two.step(t) assert_almost_eq(float(single), 600.0, 2.0) assert_almost_eq(float(double), 1200.0, 2.0, "two doses must deliver two doses' worth") func test_a_dose_delivers_its_whole_value_over_the_window() -> void: var track := PoisonTrack.new() track.add(300.0, 0) var total := 0 for t in SimConfig.POISON_DURATION_TICKS: total += track.step(t) assert_eq(total, 300, "the whole dose, to the hit point") track.step(SimConfig.POISON_DURATION_TICKS) assert_false(track.active(), "and then stops") ## Doses all last the same number of ticks, so they expire in the order they ## were added. That is the property that lets the queue be a plain FIFO whose ## front is the only entry ever examined -- and therefore the property that ## makes dozens of concurrent stacks cost nothing. func test_poison_expires_in_the_order_it_was_applied() -> void: var track := PoisonTrack.new() track.add(600.0, 0) track.add(600.0, 300) assert_eq(track.dose_count(), 2) for t in range(0, SimConfig.POISON_DURATION_TICKS + 1): track.step(t) assert_true(track.active(), "the later dose is still running") assert_eq(track.dose_count(), 1, "the earlier one retired first") for t in range(SimConfig.POISON_DURATION_TICKS + 1, 300 + SimConfig.POISON_DURATION_TICKS + 1): track.step(t) assert_false(track.active()) assert_eq(track.dose_count(), 0) func test_fractions_below_one_hit_point_are_not_lost() -> void: var track := PoisonTrack.new() # 3 damage over 600 ticks is 0.005/tick -- it would round to nothing on # every single tick without the carry. track.add(3.0, 0) var total := 0 for t in SimConfig.POISON_DURATION_TICKS: total += track.step(t) assert_eq(total, 3) ## Poison must be able to finish something off, or a kill it caused would never ## be scored and the experience would vanish. func test_a_kill_by_poison_is_still_announced() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 3 e.poison_track().add(600.0, world.tick) var died := false for _i in 120: world.step() for ev in world.events: if int(ev["t"]) == SimEvent.Type.ENEMY_DIED: died = true world.drain_events() assert_true(died, "experience is keyed on this event") ## Poison lands many times a second. Putting each tick on the reliable event ## channel would be a flood, and the client learns hp from the snapshot anyway. func test_poison_ticks_are_not_announced_as_hits() -> void: var e := world.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 100000 e.poison_track().add(6000.0, world.tick) world.drain_events() for _i in 120: world.step() var hits := 0 for ev in world.events: if int(ev["t"]) == SimEvent.Type.ENEMY_HIT: hits += 1 assert_eq(hits, 0) assert_lt(e.hp, 100000, "but the damage still happened") ## The boss takes extra damage in later phases. Poison is derived from the ## damage actually applied, so it inherits that -- and must not be scaled a ## second time when it ticks. func test_boss_poison_is_scaled_once_not_twice() -> void: var boss := world.spawn_boss(Content.warden()) boss.pos = Vector2(150.0, 0.0) # Hurt into a phase that actually has an armour multiplier -- at full health # the multiplier is 1.0 and scaling twice is indistinguishable from scaling # once, which is exactly how this test used to pass while proving nothing. # Two things have to be true for this test to be able to fail. The boss has # to be in a phase with an armour multiplier at all -- at full health it is # 1.0, and scaling twice is then indistinguishable from scaling once. And # the per-tick poison has to be big enough that the multiplier survives # rounding: at 1 damage a tick, roundi(1 x 1.15) is still 1, so the bug # hides completely. A large pool gives both. boss.def.max_hp = 100000 boss.hp = 20000 # 20% -> a phase that takes extra world.step() var armour := boss.current_phase().damage_taken_mult assert_gt(armour, 1.0, "setup: this phase has to have armour") var track := boss.poison_track() track.add(6000.0, world.tick) # 10 damage per tick var before := boss.hp for _i in SimConfig.POISON_DURATION_TICKS + 5: world.step() var dealt := float(before - boss.hp) assert_almost_eq(dealt, 6000.0, 60.0, "scaled a second time this would be about %.0f" % (6000.0 * armour)) func test_a_replica_never_applies_poison() -> void: var replica := SimWorld.new(1) replica.authoritative = false var e := replica.spawn_enemy(Content.turret(), Vector2(150.0, 0.0)) e.hp = 500 e.poison_track().add(600.0, 0) for _i in 200: replica.step() assert_eq(e.hp, 500, "a client must never decide it dealt damage") # --- Eraser ----------------------------------------------------------------- func _enemy_bullet(at: Vector2) -> int: return world.pool.spawn(at, Vector2.ZERO, 8.0, 600, 5, SimConfig.TEAM_ENEMY, SimConfig.KIND_ORB) ## Certainty rather than 1%, so the test measures the mechanism instead of the ## random number generator. func test_an_erasing_shot_deletes_an_enemy_projectile_it_overlaps() -> void: var target := Vector2(100.0, 0.0) var incoming := _enemy_bullet(target) _shoot_at(target, 0, 0.0, 1.0) world.step() assert_eq(world.pool.alive[incoming], 0) func test_the_deletion_is_announced_so_no_client_keeps_a_phantom() -> void: var target := Vector2(100.0, 0.0) var incoming := _enemy_bullet(target) var incoming_uid: int = world.pool.uid[incoming] world.drain_events() _shoot_at(target, 0, 0.0, 1.0) world.step() var announced := false for ev in world.events: if int(ev["t"]) == SimEvent.Type.BULLET_DESPAWN and int(ev["uid"]) == incoming_uid: announced = true assert_true(announced) func test_a_shot_without_the_upgrade_passes_straight_through() -> void: var target := Vector2(100.0, 0.0) var incoming := _enemy_bullet(target) _shoot_at(target, 0, 0.0, 0.0) world.step() assert_eq(world.pool.alive[incoming], 1, "enemy bullets are not obstacles for ordinary shots") func test_an_erasing_shot_is_not_consumed_by_erasing() -> void: var target := Vector2(100.0, 0.0) _enemy_bullet(target) var mine := _shoot_at(target, 0, 0.0, 1.0) world.step() assert_eq(world.pool.alive[mine], 1, "the shot keeps going") ## One roll per shot per tick, not one per overlapping pair. A boss ring puts ## dozens of bullets in the same place, and rolling against each would multiply ## the 1% the player was promised. func test_only_one_projectile_is_erased_per_tick() -> void: var target := Vector2(100.0, 0.0) for _i in 5: _enemy_bullet(target) _shoot_at(target, 0, 0.0, 1.0) world.step() var left := 0 for i in world.pool.high_water: if world.pool.alive[i] == 1 and world.pool.team[i] == SimConfig.TEAM_ENEMY: left += 1 assert_eq(left, 4, "four of the five survive the tick")