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.ALL_ENEMIES: 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)