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>
227 lines
8.2 KiB
GDScript
227 lines
8.2 KiB
GDScript
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")
|