b351bc2d55
ci / verify (push) Successful in 48s
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>
418 lines
14 KiB
GDScript
418 lines
14 KiB
GDScript
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")
|