Stage 4: upgrades, and a quartermaster to spend them at
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>
This commit is contained in:
2026-09-06 15:34:58 +02:00
parent a943aa19f6
commit b351bc2d55
47 changed files with 2438 additions and 92 deletions
+25 -7
View File
@@ -84,15 +84,33 @@ func test_bullets_stopped_by_a_wall_are_logged_for_announcement() -> void:
assert_true(pool.wall_kill_log.has(id), "wall deaths must be announceable")
## Wall collision samples the bullet's position once per tick, so a bullet that
## travels more than one tile per tick can step straight over a wall. Nothing in
## the game comes close today, but a future "fast projectile" upgrade could, and
## it would look like walls randomly failing.
## 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.PLAYER_BULLET_SPEED * 2.0, limit,
"even at the doubled speed an upgrade could grant, a bullet must not " +
"cross a whole tile in one tick or it will tunnel through walls")
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:
+102
View File
@@ -210,3 +210,105 @@ func test_setting_an_inventory_on_a_missing_character_is_harmless() -> void:
var nothing: Array[StringName] = [Items.HEALTH_POTION]
store.set_inventory(ACC, "no-such-id", nothing)
assert_eq(store.characters_for(ACC).size(), 0)
# --- Upgrades ---------------------------------------------------------------
func test_a_level_banks_a_choice_and_puts_three_on_the_table() -> void:
var c := store.create_character(ACC, "Riser")
store.grant_choices(ACC, c.id, 1)
assert_eq(c.pending_choices, 1)
assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES)
## The offer is rolled once and held. If it regenerated per request, closing and
## reopening the screen would be a free reroll until a legendary appeared.
func test_the_offer_is_stable_until_something_is_taken() -> void:
var c := store.create_character(ACC, "Picky")
store.grant_choices(ACC, c.id, 1)
var first := c.offer.duplicate()
store.grant_choices(ACC, c.id, 1)
assert_eq(c.offer, first, "banking another level must not reroll the table")
## Two levels in one run owe two choices. Losing one for doing well is a
## punishment nobody would guess at.
func test_choices_queue() -> void:
var c := store.create_character(ACC, "Fast")
store.grant_choices(ACC, c.id, 3)
assert_eq(c.pending_choices, 3)
store.take_upgrade(ACC, c.id, 0)
assert_eq(c.pending_choices, 2)
assert_eq(c.offer.size(), SimConfig.UPGRADE_CHOICES, "and the next three appear")
func test_taking_an_upgrade_records_it_and_rerolls_the_table() -> void:
var c := store.create_character(ACC, "Taker")
store.grant_choices(ACC, c.id, 1)
var wanted: StringName = c.offer[1]
var taken := store.take_upgrade(ACC, c.id, 1)
assert_eq(taken, wanted)
assert_eq(c.upgrades, [wanted] as Array[StringName])
assert_eq(c.pending_choices, 0)
assert_true(c.offer.is_empty(), "nothing pending means nothing on the table")
func test_taking_without_a_pending_choice_is_refused() -> void:
var c := store.create_character(ACC, "Greedy")
assert_eq(store.take_upgrade(ACC, c.id, 0), &"")
assert_eq(c.upgrades.size(), 0)
## The index arrives from a client, so nonsense has to be a refusal rather than
## a crash or a free upgrade.
func test_an_index_outside_the_offer_is_refused() -> void:
var c := store.create_character(ACC, "Sneaky")
store.grant_choices(ACC, c.id, 1)
assert_eq(store.take_upgrade(ACC, c.id, -1), &"")
assert_eq(store.take_upgrade(ACC, c.id, 99), &"")
assert_eq(c.pending_choices, 1, "and costs nothing")
func test_a_dead_character_cannot_spend_a_choice() -> void:
var c := store.create_character(ACC, "Late")
store.grant_choices(ACC, c.id, 1)
store.retire_character(ACC, c.id)
assert_eq(store.take_upgrade(ACC, c.id, 0), &"")
func test_upgrades_and_pending_choices_survive_a_reload() -> void:
var c := store.create_character(ACC, "Persistent")
store.grant_choices(ACC, c.id, 2)
var taken := store.take_upgrade(ACC, c.id, 0)
var offer := c.offer.duplicate()
var reloaded := CharacterStore.new(store._path)
assert_true(reloaded.load_from_disk())
var got := reloaded.get_character(ACC, c.id)
assert_eq(got.upgrades, [taken] as Array[StringName])
assert_eq(got.pending_choices, 1)
assert_eq(got.offer, offer,
"the table has to survive a restart, or it is a reroll on every crash")
## Upgrades change what a character IS, so the roster has to show the real
## number rather than the one the level alone implies.
func test_the_roster_health_reflects_the_upgrades_taken() -> void:
var c := store.create_character(ACC, "Fragile")
var before := c.max_hp()
c.upgrades.append(Upgrades.GLASS_CANNON)
assert_eq(c.max_hp(), before / 2)
## A save written by a build with an upgrade this one lacks must not leave a
## phantom that counts toward the flat damage bonus and does nothing else.
func test_an_unknown_upgrade_in_a_save_is_dropped() -> void:
var restored := Character.from_dict({
"id": "z", "name": "Old", "xp": 0,
"upgrades": ["sniper", "telekinesis"],
"pending_choices": 2,
"offer": ["poison", "not_a_thing"],
})
assert_eq(restored.upgrades, [Upgrades.SNIPER] as Array[StringName])
assert_eq(restored.offer, [Upgrades.POISON] as Array[StringName])
assert_eq(restored.pending_choices, 2)
+17 -1
View File
@@ -83,7 +83,11 @@ func test_omitting_the_observer_encodes_the_whole_world() -> void:
## real content rather than guessed. Adding a faster or longer-lived bullet
## should fail here rather than produce invisible damage in play.
func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
var worst := SimConfig.PLAYER_BULLET_SPEED \
# The player's shot is the longest-travelling bullet in the game once
# upgrades are involved, so the worst case is measured at the speed ceiling
# rather than at the base speed. test_interest used to model only static
# content, which upgrades quietly invalidated.
var worst := SimConfig.MAX_BULLET_SPEED \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
@@ -95,3 +99,15 @@ func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
"a bullet spawned just outside the radius must not be able to reach " +
"ground the player can see before it expires (worst travel %.0f)" % worst)
## The ceiling is what makes the radius above computable at all. Without it a
## stack of Snipers would push bullet travel past whatever radius was chosen,
## and the failure would be invisible damage rather than a failing test.
func test_no_stack_of_upgrades_can_outrun_the_bullet_radius() -> void:
var ids: Array[StringName] = []
for _i in 8:
ids.append(Upgrades.SNIPER)
var travel := PlayerStats.build(ids).bullet_speed \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, travel + SimConfig.FOG_VIEW_RADIUS)
+47
View File
@@ -436,3 +436,50 @@ func test_an_empty_portal_list_is_safe() -> void:
assert_eq(NetCodec.decode_portals(
NetCodec.encode_portals([] as Array[SimPortal])).size(), 0)
assert_eq(NetCodec.decode_portals(PackedByteArray()).size(), 0)
# --- Upgrade state ----------------------------------------------------------
func test_upgrade_state_round_trips() -> void:
var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON, Upgrades.ERASER]
var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.SPREAD, Upgrades.GLASS_CANNON]
var out := NetCodec.decode_upgrade_state(
NetCodec.encode_upgrade_state(2, offer, taken))
assert_eq(int(out["pending"]), 2)
assert_eq(out["offer"], offer)
assert_eq(out["taken"], taken, "duplicates are meaningful and must survive")
func test_an_empty_upgrade_state_is_safe() -> void:
var none: Array[StringName] = []
var out := NetCodec.decode_upgrade_state(
NetCodec.encode_upgrade_state(0, none, none))
assert_eq(int(out["pending"]), 0)
assert_eq((out["offer"] as Array).size(), 0)
assert_eq((out["taken"] as Array).size(), 0)
assert_eq(int(NetCodec.decode_upgrade_state(PackedByteArray())["pending"]), 0)
func test_a_truncated_upgrade_packet_does_not_read_past_the_end() -> void:
var offer: Array[StringName] = [Upgrades.SNIPER, Upgrades.POISON]
var taken: Array[StringName] = [Upgrades.SPREAD, Upgrades.DOUBLESHOT, Upgrades.ERASER]
var full := NetCodec.encode_upgrade_state(1, offer, taken)
for cut in range(1, full.size()):
var out := NetCodec.decode_upgrade_state(full.slice(0, cut))
assert_lte((out["offer"] as Array).size(), 2)
assert_lte((out["taken"] as Array).size(), 3)
assert_eq((NetCodec.decode_upgrade_state(full)["taken"] as Array).size(), 3)
## An index this build does not know decodes to nothing and is dropped, rather
## than becoming whatever upgrade happens to sit at that position.
func test_an_unknown_upgrade_index_is_dropped_not_guessed() -> void:
var b := StreamPeerBuffer.new()
b.big_endian = false
b.put_u8(1)
b.put_u8(2)
b.put_u8(Upgrades.index_of(Upgrades.SNIPER))
b.put_u8(200)
b.put_u16(0)
var out := NetCodec.decode_upgrade_state(b.data_array)
assert_eq(out["offer"], [Upgrades.SNIPER] as Array[StringName])
+417
View File
@@ -0,0 +1,417 @@
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")
+1
View File
@@ -0,0 +1 @@
uid://cvmvcowixcc6s
+226
View File
@@ -0,0 +1,226 @@
extends GutTest
## Upgrades: the registry, the damage formula, and how they stack.
##
## The formula is settled (see docs/DECISIONS.md): base x (1 + sum additive) x
## product multiplicative, with every upgrade adding a flat +5% to the additive
## pool on top of whatever else it does. These tests are the record of that.
const BASE_DMG := SimConfig.PLAYER_BULLET_DAMAGE
func _stats(ids: Array) -> PlayerStats:
var typed: Array[StringName] = []
for id in ids:
typed.append(id)
return PlayerStats.build(typed)
func test_every_registered_id_has_a_definition() -> void:
for id in Upgrades.ORDER:
assert_not_null(Upgrades.get_def(id), "%s is in ORDER with no definition" % id)
func test_the_wire_index_round_trips() -> void:
for id in Upgrades.ORDER:
assert_eq(Upgrades.by_index(Upgrades.index_of(id)), id)
assert_gt(Upgrades.index_of(id), 0, "0 is reserved for 'no upgrade'")
assert_eq(Upgrades.by_index(0), &"")
assert_eq(Upgrades.by_index(250), &"")
assert_eq(Upgrades.index_of(&"telekinesis"), 0)
func test_the_index_fits_the_byte_the_wire_gives_it() -> void:
assert_lte(Upgrades.ORDER.size(), 254)
func test_no_upgrades_means_exactly_the_base_numbers() -> void:
var s := _stats([])
assert_eq(s.damage, BASE_DMG)
assert_eq(s.fire_cooldown, SimConfig.PLAYER_FIRE_COOLDOWN)
assert_eq(s.bullet_speed, SimConfig.PLAYER_BULLET_SPEED)
assert_eq(s.max_hp_mult, 1.0)
assert_eq(s.upgrade_count, 0)
## The +5% is a property of taking an upgrade, not of any particular one, so
## even an upgrade with no damage effect of its own raises damage.
func test_every_upgrade_carries_the_flat_damage_bonus() -> void:
for id in Upgrades.ORDER:
var s := _stats([id])
var def := Upgrades.get_def(id)
var expected := roundi(float(BASE_DMG)
* (1.0 + SimConfig.UPGRADE_DAMAGE_BONUS + def.damage_add)
* def.damage_mult)
assert_eq(s.damage, maxi(1, expected), "%s" % id)
func test_split_shot_alone_still_raises_damage_by_the_flat_bonus() -> void:
assert_eq(_stats([Upgrades.SPLIT_SHOT]).damage,
roundi(float(BASE_DMG) * (1.0 + SimConfig.UPGRADE_DAMAGE_BONUS)))
## The settled formula, spelled out on the exact case that distinguishes it
## from the alternatives: additive percentages pool, and Sniper multiplies the
## pooled result rather than joining it.
func test_the_damage_formula_is_additive_pool_times_multiplicative() -> void:
var s := _stats([Upgrades.GLASS_CANNON, Upgrades.SPREAD, Upgrades.SNIPER])
# +5% x3 = +15%, glass cannon +100%, spread -10% -> pool = 2.05
# sniper x2 on top.
assert_eq(s.damage, roundi(float(BASE_DMG) * 2.05 * 2.0))
func test_two_snipers_multiply_rather_than_add() -> void:
var one := _stats([Upgrades.SNIPER]).damage
var two := _stats([Upgrades.SNIPER, Upgrades.SNIPER]).damage
# If Sniper were additive, two of them would be far less than four times
# the base. Multiplicative means the second is worth as much as the first.
assert_gt(float(two) / float(one), 1.9)
func test_damage_never_falls_below_one() -> void:
var stacked: Array[StringName] = []
for _i in 6:
stacked.append(Upgrades.DOUBLESHOT) # -50% each
assert_gte(PlayerStats.build(stacked).damage, 1,
"a shot that deals nothing is indistinguishable from a bug")
# --- Fire rate, speed, health ------------------------------------------------
## "Half the fire rate" has to mean half the shots per second, which is a
## doubled cooldown -- halving the cooldown would do the opposite of the brief.
func test_sniper_halves_the_shots_per_second() -> void:
assert_eq(_stats([Upgrades.SNIPER]).fire_cooldown,
SimConfig.PLAYER_FIRE_COOLDOWN * 2)
func test_the_cooldown_never_reaches_zero() -> void:
var many: Array[StringName] = []
for _i in 10:
many.append(Upgrades.SNIPER)
assert_gte(PlayerStats.build(many).fire_cooldown, 1)
func test_sniper_doubles_bullet_speed_up_to_the_ceiling() -> void:
assert_eq(_stats([Upgrades.SNIPER]).bullet_speed,
SimConfig.PLAYER_BULLET_SPEED * 2.0)
assert_false(_stats([Upgrades.SNIPER]).speed_capped)
var two := _stats([Upgrades.SNIPER, Upgrades.SNIPER])
assert_eq(two.bullet_speed, SimConfig.MAX_BULLET_SPEED)
assert_true(two.speed_capped, "the screen has to be able to say so")
## Half of the LEVELLED maximum, so the trade stays a real price at level 15
## instead of fading to a rounding error.
func test_glass_cannon_halves_the_levelled_maximum() -> void:
assert_eq(_stats([Upgrades.GLASS_CANNON]).max_hp_mult, 0.5)
var p := SimPlayer.new()
p.level = Progression.MAX_LEVEL
p.stats = _stats([Upgrades.GLASS_CANNON])
p.recompute_max_hp()
assert_eq(p.max_hp, Progression.max_hp_for_level(Progression.MAX_LEVEL) / 2)
func test_two_glass_cannons_multiply() -> void:
assert_almost_eq(_stats([Upgrades.GLASS_CANNON, Upgrades.GLASS_CANNON]).max_hp_mult,
0.25, 0.0001)
func test_health_can_never_be_scaled_to_zero() -> void:
var many: Array[StringName] = []
for _i in 12:
many.append(Upgrades.GLASS_CANNON)
var p := SimPlayer.new()
p.stats = PlayerStats.build(many)
p.recompute_max_hp()
assert_gte(p.max_hp, 1)
func test_dropping_below_the_new_maximum_is_immediate() -> void:
var p := SimPlayer.new()
p.hp = p.max_hp
p.stats = _stats([Upgrades.GLASS_CANNON])
p.recompute_max_hp()
assert_eq(p.hp, p.max_hp, "taking glass cannon at full health must not leave you over cap")
# --- Shot counts -------------------------------------------------------------
func test_spread_and_doubleshot_add_projectiles() -> void:
assert_eq(_stats([Upgrades.SPREAD]).side_shots, 2)
assert_eq(_stats([Upgrades.SPREAD, Upgrades.SPREAD]).side_shots, 4)
assert_eq(_stats([Upgrades.DOUBLESHOT]).parallel_shots, 1)
func test_split_charges_accumulate() -> void:
assert_eq(_stats([Upgrades.SPLIT_SHOT]).split_charges, 1)
assert_eq(_stats([Upgrades.SPLIT_SHOT, Upgrades.SPLIT_SHOT]).split_charges, 2)
func test_erase_chance_is_clamped_to_certainty() -> void:
var many: Array[StringName] = []
for _i in 200:
many.append(Upgrades.ERASER)
assert_lte(PlayerStats.build(many).erase_chance, 1.0)
## A save from a build with an upgrade this one lacks must produce coherent
## stats, not a phantom that counts toward the +5% and does nothing else.
func test_an_unknown_upgrade_is_ignored_entirely() -> void:
var mixed: Array[StringName] = [Upgrades.SNIPER, &"telekinesis"]
var s := PlayerStats.build(mixed)
assert_eq(s.upgrade_count, 1)
assert_eq(s.damage, _stats([Upgrades.SNIPER]).damage)
# --- Offers ------------------------------------------------------------------
func test_an_offer_has_the_configured_number_of_distinct_options() -> void:
var rng := RandomNumberGenerator.new()
for seed_value in 40:
rng.seed = seed_value
var offer := Upgrades.roll_offer(rng, SimConfig.UPGRADE_CHOICES)
assert_eq(offer.size(), SimConfig.UPGRADE_CHOICES)
var seen := {}
for id in offer:
assert_false(seen.has(id), "the same option twice in one offer")
seen[id] = true
assert_not_null(Upgrades.get_def(id))
## Rarity has to actually mean something, or the weights are decoration.
func test_commons_come_up_far_more_often_than_legendaries() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 99
var counts := {}
for _i in 600:
for id in Upgrades.roll_offer(rng, 1):
counts[id] = int(counts.get(id, 0)) + 1
var commons := int(counts.get(Upgrades.SPLIT_SHOT, 0))
var legendary := int(counts.get(Upgrades.ERASER, 0))
assert_gt(commons, legendary * 3,
"a legendary that turns up as often as a common is not a legendary")
## Asking for more options than exist must terminate rather than spin looking
## for a distinct one it can never find.
func test_asking_for_more_options_than_exist_is_safe() -> void:
var rng := RandomNumberGenerator.new()
rng.seed = 3
var offer := Upgrades.roll_offer(rng, Upgrades.ORDER.size() + 5)
assert_eq(offer.size(), Upgrades.ORDER.size())
## Duplicates across offers are the point: taking Split Shot twice is how a
## shot splits twice, so an upgrade you hold must still be offerable.
func test_an_upgrade_you_already_hold_can_be_offered_again() -> void:
var rng := RandomNumberGenerator.new()
var seen_repeat := false
for seed_value in 60:
rng.seed = seed_value
var a := Upgrades.roll_offer(rng, 1)
var b := Upgrades.roll_offer(rng, 1)
if not a.is_empty() and a == b:
seen_repeat = true
break
assert_true(seen_repeat, "offers must not exclude what you already have")
+1
View File
@@ -0,0 +1 @@
uid://dx1wdyhuevon8