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")