Files
transcience/tests/unit/test_content.gd
T
claude cb2c1e7840 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>
2026-09-06 16:16:38 +02:00

151 lines
5.8 KiB
GDScript

extends GutTest
## Invariants every piece of content has to satisfy. These are cheap to write
## and they are what stops a new enemy from quietly reintroducing a mechanic the
## game has decided against.
const HOSTILES := [
Content.ENEMY_DRIFTER,
Content.ENEMY_TURRET,
Content.ENEMY_STALKER,
]
## The rule: nothing in this game hurts you by touching you. Every threat is a
## bullet you can see and dodge, so an enemy with no emitters is either inert
## scenery or a design mistake.
func test_every_hostile_enemy_actually_fires_something() -> void:
for id in HOSTILES:
var def := Content.enemy(id)
assert_gt(def.emitters.size(), 0,
"%s has no emitters -- it would be a harmless obstacle" % id)
func test_the_practice_dummy_is_inert() -> void:
var def := Content.enemy(Content.ENEMY_DUMMY)
assert_eq(def.emitters.size(), 0, "the hub target must never shoot back")
## Standing inside an enemy costs nothing by itself. Uses the dummy so the test
## measures contact alone, with no bullets in play to confuse the result.
func test_touching_an_enemy_deals_no_damage() -> void:
var world := SimWorld.new(1)
var p := world.add_player(1, "tester")
var e := world.spawn_enemy(Content.dummy(), Vector2.ZERO)
p.pos = e.pos
p.spawn_grace = 0
for _i in 120:
world.step()
assert_eq(p.hp, SimConfig.PLAYER_MAX_HP,
"contact damage is not a damage type in this game")
## The Stalker's burst replaces its old contact damage, so it has to be a real
## threat once it closes -- and harmless from across the arena.
func test_the_stalker_threatens_at_point_blank_only() -> void:
var near := SimWorld.new(1)
var pn := near.add_player(1, "tester")
pn.pos = Vector2.ZERO
pn.spawn_grace = 0
near.spawn_enemy(Content.stalker(), Vector2(40.0, 0.0))
for _i in 90:
near.step()
assert_lt(pn.hp, SimConfig.PLAYER_MAX_HP, "point blank has to hurt")
var far := SimWorld.new(1)
var pf := far.add_player(1, "tester")
pf.pos = Vector2(0.0, 300.0)
pf.spawn_grace = 0
# Pinned in place so it cannot close the distance during the test.
var e := far.spawn_enemy(Content.stalker(), Vector2(0.0, -300.0))
e.def = Content.stalker()
e.def.speed = 0.0
for _i in 90:
far.step()
assert_eq(pf.hp, SimConfig.PLAYER_MAX_HP,
"its bullets must expire long before they cross the arena")
func test_enemy_bullets_from_the_stalker_do_not_litter_the_arena() -> void:
var world := SimWorld.new(1)
var p := world.add_player(1, "tester")
p.pos = Vector2(0.0, 300.0)
var e := world.spawn_enemy(Content.stalker(), Vector2(0.0, -300.0))
e.def = Content.stalker()
e.def.speed = 0.0
for _i in 300:
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)