Scale health and damage by ten so percentages have somewhere to land
A shot is 60, a fresh character has 1000, the Warden has 36000. Enemy health, boss health and every emitter's damage were scaled with them, so every ratio is unchanged and time to kill is exactly what it was -- tests/unit/test_content now pins shots-to-kill per enemy and hits-to-kill a player so a careless edit to either side shows up as a number a designer recognises. The reason is rounding rather than balance. Damage is an integer, and at a base of 6 the +5% every upgrade carries computed to 6.3 and rounded straight back to 6: a player took their first upgrade, was told it made them stronger, and nothing happened. diag_upgrades used to print "damage matches the formula (6 -> 6)"; it now prints (60 -> 63). The boss's per-phase armour multiplier had the same problem, turning 1.15 into an effective 1.17. The practice dummy stopped relying on a huge health pool at the same time. Its old 100000 was already past the u16 the snapshot sends enemy health in, and once a shot did 60 a patient player could have destroyed the hub's only practice target for everyone until the next restart. EnemyDef.indestructible says what was actually meant, and a test asserts nothing else uses it. check.sh clean, 364 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -76,3 +76,75 @@ func test_enemy_bullets_from_the_stalker_do_not_litter_the_arena() -> void:
|
||||
world.step()
|
||||
assert_lt(world.pool.live_count, 12,
|
||||
"a short lifetime should keep spent point-blank shots from accumulating")
|
||||
|
||||
|
||||
# --- The scale of the numbers -----------------------------------------------
|
||||
|
||||
## Health and damage were multiplied by ten so that percentage modifiers have
|
||||
## somewhere to land: at the old base of 6 damage, the +5% every upgrade carries
|
||||
## rounded straight back to 6 and a player's first upgrade did nothing visible.
|
||||
## These tests pin the properties that made the rescale worth doing, and the
|
||||
## ratios it had to leave alone.
|
||||
func test_a_single_upgrade_visibly_changes_damage() -> void:
|
||||
var none := PlayerStats.build([] as Array[StringName])
|
||||
for id in Upgrades.ORDER:
|
||||
var one := PlayerStats.build([id] as Array[StringName])
|
||||
assert_ne(one.damage, none.damage,
|
||||
"%s carries +%d%% damage and must not round away" % [
|
||||
id, roundi(SimConfig.UPGRADE_DAMAGE_BONUS * 100.0)])
|
||||
|
||||
|
||||
## Time to kill is the balance-relevant number, and the rescale was explicitly
|
||||
## not allowed to change it. Written as shots rather than as a ratio so a
|
||||
## careless edit to either side shows up as a number a designer recognises.
|
||||
func test_shots_to_kill_is_what_it_was_before_the_rescale() -> void:
|
||||
var expected := {
|
||||
Content.ENEMY_DRIFTER: 7, # 400 hp / 60
|
||||
Content.ENEMY_TURRET: 12, # 700 hp / 60
|
||||
Content.ENEMY_STALKER: 5, # 300 hp / 60
|
||||
}
|
||||
for id in expected:
|
||||
var def := Content.enemy(id)
|
||||
var shots := ceili(float(def.max_hp) / float(SimConfig.PLAYER_BULLET_DAMAGE))
|
||||
assert_eq(shots, int(expected[id]), "%s takes %d shots" % [id, shots])
|
||||
|
||||
|
||||
## Likewise for how long an unarmoured player survives standing in the open.
|
||||
func test_hits_to_kill_a_player_is_what_it_was() -> void:
|
||||
var base := SimConfig.PLAYER_MAX_HP
|
||||
var worst := 0
|
||||
for id in HOSTILES:
|
||||
for e in Content.enemy(id).emitters:
|
||||
worst = maxi(worst, e.damage)
|
||||
assert_eq(worst, 140, "the stalker's point-blank pellet is still the hardest hit")
|
||||
assert_eq(base / worst, 7, "and still takes this many to drop a fresh character")
|
||||
|
||||
|
||||
## Enemy health rides the snapshot as a u16. Anything above that is silently
|
||||
## misreported rather than rejected, which is exactly the kind of bug that
|
||||
## survives a rescale unnoticed -- the old practice dummy was already past it.
|
||||
func test_every_enemy_fits_the_health_field_the_wire_gives_it() -> void:
|
||||
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
|
||||
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
|
||||
assert_lte(Content.enemy(id).max_hp, 65535, "%s is too big for the wire" % id)
|
||||
|
||||
|
||||
## A levelled character's health rides the same kind of field.
|
||||
func test_a_capped_character_fits_the_health_field_too() -> void:
|
||||
assert_lte(Progression.max_hp_for_level(Progression.MAX_LEVEL), 65535)
|
||||
|
||||
|
||||
## The hub's practice target has to still be there tomorrow. Expressed as a
|
||||
## flag, so a patient player cannot wear it down and leave the hub without one.
|
||||
func test_the_practice_dummy_cannot_be_destroyed() -> void:
|
||||
var world := SimWorld.new(1)
|
||||
var e := world.spawn_enemy(Content.dummy(), Vector2(200.0, 0.0))
|
||||
world._damage_enemy(e, 10_000_000)
|
||||
assert_true(e.alive)
|
||||
assert_eq(e.hp, e.def.max_hp, "and takes no damage at all")
|
||||
|
||||
|
||||
func test_nothing_else_is_indestructible() -> void:
|
||||
for id in HOSTILES:
|
||||
assert_false(Content.enemy(id).indestructible,
|
||||
"%s must be killable, or it is scenery" % id)
|
||||
|
||||
@@ -39,7 +39,9 @@ func test_fractions_accumulate_rather_than_being_lost() -> void:
|
||||
|
||||
func test_it_scales_with_maximum_health_so_levels_do_not_dilute_it() -> void:
|
||||
var low := _player()
|
||||
low.max_hp = 100
|
||||
# Taken from the curve rather than written down, so a rescale of the health
|
||||
# numbers cannot leave this test comparing two arbitrary constants.
|
||||
low.max_hp = Progression.max_hp_for_level(Progression.START_LEVEL)
|
||||
low.hp = 1
|
||||
for _i in SimConfig.TICK_RATE:
|
||||
world.step()
|
||||
@@ -47,7 +49,7 @@ func test_it_scales_with_maximum_health_so_levels_do_not_dilute_it() -> void:
|
||||
|
||||
var world2 := SimWorld.new(1)
|
||||
var high := world2.add_player(PEER, "tester")
|
||||
high.max_hp = 240
|
||||
high.max_hp = Progression.max_hp_for_level(Progression.MAX_LEVEL)
|
||||
high.hp = 1
|
||||
for _i in SimConfig.TICK_RATE:
|
||||
world2.step()
|
||||
|
||||
Reference in New Issue
Block a user