From cb2c1e7840132e9587a71ccc4a8751aaf69a3a8b Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 16:16:38 +0200 Subject: [PATCH] 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 --- docs/DECISIONS.md | 22 ++++++++++ docs/ROADMAP.md | 41 +++++++++++-------- resources/bosses/warden.tres | 18 +++++---- resources/enemies/drifter.tres | 3 +- resources/enemies/dummy.tres | 3 +- resources/enemies/stalker.tres | 4 +- resources/enemies/turret.tres | 3 +- src/actors/enemies/enemy_def.gd | 5 +++ src/content/content.gd | 44 +++++++++++--------- src/core/sim_config.gd | 11 ++++- src/meta/progression.gd | 5 ++- src/sim/sim_world.gd | 2 + tests/unit/test_content.gd | 72 +++++++++++++++++++++++++++++++++ tests/unit/test_regen.gd | 6 ++- 14 files changed, 185 insertions(+), 54 deletions(-) diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index c6cd7ca..fb7fabc 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -340,3 +340,25 @@ pins the clamp. reliable channel, and the client learns enemy health from the snapshot anyway. Death is still announced, because the experience award is keyed on that event and a kill by poison has to score. + +--- + +## The scale of the numbers + +**Health and damage are ten times what they started as, and every ratio is +unchanged.** A shot is 60, a fresh character has 1000, the Warden has 36000, +and enemy health and emitter damage were scaled with them — time to kill is +exactly what it was. + +The reason is rounding, not balance. Damage is an integer, and at a base of 6 +the +5% that every upgrade carries computed to 6.3 and rounded back to 6: a +player took their first upgrade, was told it made them stronger, and it did +nothing. Ten times the base means every 5% step is worth 3 damage. The same +applies to the boss's per-phase armour multiplier, which at 6 damage rounded +1.15 into 1.17. + +**The practice dummy is indestructible by flag, not by a large number.** The +old 100000 health was already past the u16 the snapshot sends enemy health in, +and once a shot did 60 a patient player could have worn it down and left the +hub without a practice target until the next restart. A flag says what was +actually meant. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 96eb8b0..c1e7d6f 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -20,7 +20,7 @@ What "everything passes" currently means. Numbers move; the shape does not. | Gate | Covers | Runtime | | --- | --- | --- | | `tools/check.sh` | every script parses and type-checks | ~5s | -| `tools/test.sh` | 357 GUT tests, no SceneTree | ~4s | +| `tools/test.sh` | 364 GUT tests, no SceneTree | ~4s | | `tools/smoke.sh` | 18 assertions over a real ENet socket: handshake, auth, character creation and persistence, both dungeon kinds, escape, hard kill, polite disconnect | ~40s | | `diag_prediction.tscn` | client-prediction gap, with injected clock drift | ~10s | | `diag_progression.tscn` | kill → xp → level → health, death → retire → roster, swap guards | ~10s | @@ -219,6 +219,29 @@ shares the interact key. --- +## The scale of health and damage + +Health and damage are an order of magnitude larger than they started. A shot is +60, a fresh character has 1000, the Warden has 36000. **Every ratio is +unchanged** — enemy health, boss health and every emitter's damage were scaled +together, so time to kill is exactly what it was. + +The reason is rounding. At the old base of 6 damage, the +5% every upgrade +carries computed to 6.3 and rounded straight back to 6, so a player's first +upgrade visibly did nothing. At 60 it is +3, and every percentage in the game +now has somewhere to land. + +`tests/unit/test_content.gd` pins both halves of that: that a single upgrade +changes damage, and that shots-to-kill for each enemy is what it was before. +Do not tidy these numbers back down without scaling `content.gd` with them. + +The hub's practice dummy stopped relying on a huge health pool at the same +time: it is `indestructible` by flag now. The old 100000 was already past the +u16 the snapshot sends enemy health in, and after the rescale a patient player +could have destroyed the hub's only practice target for everyone. + +--- + ## Stage 4 — Upgrades · *done* Every level gained banks one choice. Choices are spent at the **quartermaster**, @@ -284,9 +307,6 @@ All four settled with the user; the reasoning is in ### Known gaps -- **The +5% is invisible on the first upgrade.** `PLAYER_BULLET_DAMAGE` is 6, so - 6 × 1.05 rounds back to 6. It accumulates correctly (14 upgrades → +70% → 10 - damage), but the first one visibly does nothing. See the open question below. - **One panel, not two screens.** The brief asked for a separate screen listing upgrades taken; it is the lower half of the choice panel instead, because the two are read together. @@ -337,19 +357,6 @@ Not oversights — each was considered and rejected for now, with the reasoning Genuinely unspecified. **Do not guess at these** — each changes the design, and several have no obvious default. -### Worth a decision soon - -1. **Base damage is too small for the +5% to show.** `PLAYER_BULLET_DAMAGE` is - 6, and 6 × 1.05 rounds to 6 — so the flat bonus every upgrade carries does - nothing at all until the second one. It comes out right in aggregate, but a - player who takes Split Shot and sees no damage change has been told - something untrue. - - The clean fix is to scale base damage and enemy health together — ×4 would - put a shot at 24 and make every 5% step land — which changes no time-to-kill - but touches every number in `content.gd`. That is a balance edit, so it is - yours to call rather than mine. - ### Blocking nothing yet 8. **What advances dungeon depth?** `--depth` is a dev flag; nothing raises it diff --git a/resources/bosses/warden.tres b/resources/bosses/warden.tres index ac370de..e46b59a 100644 --- a/resources/bosses/warden.tres +++ b/resources/bosses/warden.tres @@ -27,7 +27,7 @@ interval = 45 speed = 135.0 radius = 8.0 lifetime = 420 -damage = 14 +damage = 140 [sub_resource type="Resource" id="Resource_a0mrn"] script = ExtResource("5_v02no") @@ -37,6 +37,7 @@ start_tick = 120 interval = 150 speed = 210.0 radius = 6.0 +damage = 120 kind = 2 [sub_resource type="Resource" id="Resource_pfjxl"] @@ -54,6 +55,7 @@ muzzle_offset = 46.0 interval = 9 speed = 120.0 lifetime = 480 +damage = 120 [sub_resource type="Resource" id="Resource_k1e8f"] script = ExtResource("6_x6ufy") @@ -63,7 +65,7 @@ start_tick = 90 interval = 170 speed = 175.0 radius = 8.0 -damage = 16 +damage = 160 kind = 3 [sub_resource type="Resource" id="Resource_4sp1g"] @@ -83,7 +85,7 @@ sweep_period = 5.0 interval = 5 speed = 165.0 lifetime = 400 -damage = 13 +damage = 130 [sub_resource type="Resource" id="Resource_3fm5w"] script = ExtResource("5_v02no") @@ -95,6 +97,7 @@ start_tick = 60 interval = 110 speed = 195.0 radius = 6.0 +damage = 120 kind = 2 [sub_resource type="Resource" id="Resource_20c8f"] @@ -106,6 +109,7 @@ end_tick = 600 interval = 60 speed = 105.0 lifetime = 420 +damage = 120 [sub_resource type="Resource" id="Resource_ymr56"] script = ExtResource("2_f5abt") @@ -124,7 +128,7 @@ interval = 11 speed = 130.0 turn_deg = 0.55 lifetime = 400 -damage = 15 +damage = 150 [sub_resource type="Resource" id="Resource_3n6oq"] script = ExtResource("6_x6ufy") @@ -134,7 +138,7 @@ start_tick = 40 interval = 130 speed = 200.0 radius = 8.0 -damage = 18 +damage = 180 kind = 3 [sub_resource type="Resource" id="Resource_8ppic"] @@ -145,7 +149,7 @@ start_tick = 100 interval = 80 speed = 300.0 radius = 5.0 -damage = 16 +damage = 160 kind = 2 [sub_resource type="Resource" id="Resource_yqxmm"] @@ -161,7 +165,7 @@ emitters = Array[ExtResource("3_tmo3e")]([SubResource("Resource_7xac7"), SubReso script = ExtResource("8_axg03") id = &"warden" display_name = "Warden of the Fold" -max_hp = 3600 +max_hp = 36000 radius = 42.0 spawn_pos = Vector2(0, -150) phases = Array[ExtResource("2_f5abt")]([SubResource("Resource_pfjxl"), SubResource("Resource_4sp1g"), SubResource("Resource_ymr56"), SubResource("Resource_yqxmm")]) diff --git a/resources/enemies/drifter.tres b/resources/enemies/drifter.tres index 76bcb46..5dc1096 100644 --- a/resources/enemies/drifter.tres +++ b/resources/enemies/drifter.tres @@ -12,7 +12,7 @@ spread_deg = 20.0 interval = 120 radius = 6.0 lifetime = 240 -damage = 10 +damage = 100 [sub_resource type="Resource" id="Resource_lnbnj"] script = ExtResource("3_vpd04") @@ -23,6 +23,7 @@ chance = 0.08 script = ExtResource("4_k36hi") id = &"drifter" display_name = "Drifter" +max_hp = 400 speed = 55.0 emitters = Array[ExtResource("1_bjhtk")]([SubResource("Resource_rsrg2")]) pattern_loop_ticks = 120 diff --git a/resources/enemies/dummy.tres b/resources/enemies/dummy.tres index 20de167..4dd97a6 100644 --- a/resources/enemies/dummy.tres +++ b/resources/enemies/dummy.tres @@ -8,7 +8,8 @@ script = ExtResource("3_2tv1l") id = &"dummy" display_name = "Target Dummy" -max_hp = 100000 +max_hp = 1000 radius = 20.0 move = 0 visual = 3 +indestructible = true diff --git a/resources/enemies/stalker.tres b/resources/enemies/stalker.tres index 3f93463..08fc6dc 100644 --- a/resources/enemies/stalker.tres +++ b/resources/enemies/stalker.tres @@ -13,7 +13,7 @@ interval = 45 speed = 260.0 radius = 6.0 lifetime = 18 -damage = 14 +damage = 140 kind = 3 [sub_resource type="Resource" id="Resource_ep81w"] @@ -25,7 +25,7 @@ chance = 0.08 script = ExtResource("4_yp71e") id = &"stalker" display_name = "Stalker" -max_hp = 30 +max_hp = 300 radius = 12.0 move = 3 speed = 95.0 diff --git a/resources/enemies/turret.tres b/resources/enemies/turret.tres index 76a86d5..f67b032 100644 --- a/resources/enemies/turret.tres +++ b/resources/enemies/turret.tres @@ -11,6 +11,7 @@ count = 10 spin_per_shot_deg = 18.0 interval = 150 speed = 130.0 +damage = 120 [sub_resource type="Resource" id="Resource_fttpl"] script = ExtResource("3_hc01i") @@ -21,7 +22,7 @@ chance = 0.08 script = ExtResource("4_6rb75") id = &"turret" display_name = "Turret" -max_hp = 70 +max_hp = 700 radius = 16.0 move = 0 speed = 0.0 diff --git a/src/actors/enemies/enemy_def.gd b/src/actors/enemies/enemy_def.gd index 2abf368..b47e242 100644 --- a/src/actors/enemies/enemy_def.gd +++ b/src/actors/enemies/enemy_def.gd @@ -36,6 +36,11 @@ enum Move { @export var emitters: Array[BulletEmitter] = [] ## The emitter timeline wraps at this many ticks. @export var pattern_loop_ticks: int = 240 +## Never takes damage. Exists for the hub's practice target, whose whole job is +## to still be there tomorrow -- expressing that as a flag rather than as a +## large health pool means it cannot be worn down by a patient player, and +## keeps its health inside the u16 the snapshot sends. +@export var indestructible: bool = false ## What this enemy may leave behind. Rolled once per entry on death, against ## the world's own RNG. Empty for anything that should drop nothing. @export var loot: Array[LootDrop] = [] diff --git a/src/content/content.gd b/src/content/content.gd index d285a6a..b2d19fc 100644 --- a/src/content/content.gd +++ b/src/content/content.gd @@ -43,7 +43,7 @@ static func drifter() -> EnemyDef: var d := EnemyDef.new() d.id = ENEMY_DRIFTER d.display_name = "Drifter" - d.max_hp = 40 + d.max_hp = 400 d.radius = 14.0 d.move = EnemyDef.Move.DRIFT d.speed = 55.0 @@ -57,7 +57,7 @@ static func drifter() -> EnemyDef: fan.spread_deg = 20.0 fan.speed = 150.0 fan.radius = 6.0 - fan.damage = 10 + fan.damage = 100 fan.lifetime = 240 fan.kind = SimConfig.KIND_ORB d.emitters = [fan] @@ -70,7 +70,7 @@ static func turret() -> EnemyDef: var d := EnemyDef.new() d.id = ENEMY_TURRET d.display_name = "Turret" - d.max_hp = 70 + d.max_hp = 700 d.radius = 16.0 d.move = EnemyDef.Move.STATIC d.speed = 0.0 @@ -84,7 +84,7 @@ static func turret() -> EnemyDef: ring.spin_per_shot_deg = 18.0 ring.speed = 130.0 ring.radius = 7.0 - ring.damage = 12 + ring.damage = 120 ring.lifetime = 300 ring.kind = SimConfig.KIND_ORB d.emitters = [ring] @@ -104,7 +104,7 @@ static func stalker() -> EnemyDef: var d := EnemyDef.new() d.id = ENEMY_STALKER d.display_name = "Stalker" - d.max_hp = 30 + d.max_hp = 300 d.radius = 12.0 d.move = EnemyDef.Move.APPROACH d.speed = 95.0 @@ -119,7 +119,7 @@ static func stalker() -> EnemyDef: lunge.spread_deg = 62.0 lunge.speed = 260.0 lunge.radius = 6.0 - lunge.damage = 14 + lunge.damage = 140 # 18 ticks at 260 u/s is about 78px of reach -- shorter than the muzzle-to- # player distance at any range you would call "not point blank". lunge.lifetime = 18 @@ -130,12 +130,18 @@ static func stalker() -> EnemyDef: return d -## Lobby target dummy: inert, tough, so players can feel out the gun. +## Lobby target dummy: inert, indestructible, so players can feel out the gun. +## +## Indestructible by flag rather than by a huge health pool. The old 100000 was +## already past the u16 the snapshot sends enemy health in, and after the x10 +## rescale a determined player could have destroyed the hub's only practice +## target for everyone until the server restarted. static func dummy() -> EnemyDef: var d := EnemyDef.new() d.id = ENEMY_DUMMY d.display_name = "Target Dummy" - d.max_hp = 100000 + d.max_hp = 1000 + d.indestructible = true d.radius = 20.0 d.move = EnemyDef.Move.STATIC d.visual = 3 @@ -151,7 +157,7 @@ static func warden() -> BossDef: var b := BossDef.new() b.id = BOSS_WARDEN b.display_name = "Warden of the Fold" - b.max_hp = 3600 + b.max_hp = 36000 b.radius = 42.0 b.stationary = true b.spawn_pos = Vector2(0.0, -150.0) @@ -182,7 +188,7 @@ static func _warden_p1() -> BossPhase: ring.spin_per_shot_deg = 9.0 ring.speed = 135.0 ring.radius = 8.0 - ring.damage = 14 + ring.damage = 140 ring.lifetime = 420 ring.muzzle_offset = 46.0 @@ -193,7 +199,7 @@ static func _warden_p1() -> BossPhase: fan.spread_deg = 26.0 fan.speed = 210.0 fan.radius = 6.0 - fan.damage = 12 + fan.damage = 120 fan.kind = SimConfig.KIND_NEEDLE fan.muzzle_offset = 46.0 @@ -216,7 +222,7 @@ static func _warden_p2() -> BossPhase: spiral.spin_per_shot_deg = 23.0 spiral.speed = 120.0 spiral.radius = 7.0 - spiral.damage = 12 + spiral.damage = 120 spiral.lifetime = 480 spiral.muzzle_offset = 46.0 @@ -229,7 +235,7 @@ static func _warden_p2() -> BossPhase: wall.gap_step = 7 wall.speed = 175.0 wall.radius = 8.0 - wall.damage = 16 + wall.damage = 160 wall.lifetime = 300 wall.kind = SimConfig.KIND_HEAVY @@ -256,7 +262,7 @@ static func _warden_p3() -> BossPhase: arms.sweep_period = 5.0 arms.speed = 165.0 arms.radius = 7.0 - arms.damage = 13 + arms.damage = 130 arms.lifetime = 400 var fan := AimedSpreadEmitter.new() @@ -268,7 +274,7 @@ static func _warden_p3() -> BossPhase: fan.speed = 195.0 fan.edge_speed_bonus = 0.25 fan.radius = 6.0 - fan.damage = 12 + fan.damage = 120 fan.kind = SimConfig.KIND_NEEDLE var counter_ring := RingEmitter.new() @@ -279,7 +285,7 @@ static func _warden_p3() -> BossPhase: counter_ring.spin_per_shot_deg = -14.0 counter_ring.speed = 105.0 counter_ring.radius = 7.0 - counter_ring.damage = 12 + counter_ring.damage = 120 counter_ring.lifetime = 420 p.emitters = [arms, fan, counter_ring] @@ -303,7 +309,7 @@ static func _warden_p4() -> BossPhase: curve.speed = 130.0 curve.turn_deg = 0.55 curve.radius = 7.0 - curve.damage = 15 + curve.damage = 150 curve.lifetime = 400 curve.muzzle_offset = 46.0 @@ -316,7 +322,7 @@ static func _warden_p4() -> BossPhase: walls.gap_step = 5 walls.speed = 200.0 walls.radius = 8.0 - walls.damage = 18 + walls.damage = 180 walls.kind = SimConfig.KIND_HEAVY var snipe := AimedSpreadEmitter.new() @@ -326,7 +332,7 @@ static func _warden_p4() -> BossPhase: snipe.spread_deg = 10.0 snipe.speed = 300.0 snipe.radius = 5.0 - snipe.damage = 16 + snipe.damage = 160 snipe.kind = SimConfig.KIND_NEEDLE p.emitters = [curve, walls, snipe] diff --git a/src/core/sim_config.gd b/src/core/sim_config.gd index 47557f5..25257f0 100644 --- a/src/core/sim_config.gd +++ b/src/core/sim_config.gd @@ -35,12 +35,19 @@ const PLAYER_VISUAL_RADIUS := 13.0 ## the ship -- and on a remote client, the ship is also drawn a tick or two ## ahead of the server, so this margin is what absorbs that too. const PLAYER_MUZZLE_OFFSET := PLAYER_VISUAL_RADIUS + 6.0 -const PLAYER_MAX_HP := 100 +## Health and damage are deliberately an order of magnitude larger than the +## numbers they started as. Every ratio in the game is unchanged -- enemy +## health, boss health and every emitter's damage were scaled with them -- but +## percentages now have somewhere to land. At the old base of 6 damage, the +5% +## that every upgrade carries computed to 6.3 and rounded straight back to 6, +## so a player's first upgrade visibly did nothing at all. At 60 it is +3. +## Do not "tidy" these back down without scaling content.gd with them. +const PLAYER_MAX_HP := 1000 const PLAYER_FIRE_COOLDOWN := 14 # ticks (~4.3 shots/sec) const PLAYER_BULLET_SPEED := 620.0 const PLAYER_BULLET_RADIUS := 4.0 const PLAYER_BULLET_LIFETIME := 90 # ticks -const PLAYER_BULLET_DAMAGE := 6 +const PLAYER_BULLET_DAMAGE := 60 ## There are deliberately NO invulnerability frames after a hit. In a bullet ## hell the wall of bullets IS the threat, and i-frames turn a dense pattern ## into a single cheap hit -- you get punished for the first bullet and gifted diff --git a/src/meta/progression.gd b/src/meta/progression.gd index 64b57bd..b593ada 100644 --- a/src/meta/progression.gd +++ b/src/meta/progression.gd @@ -10,8 +10,9 @@ extends RefCounted const MAX_LEVEL := 15 const START_LEVEL := 1 ## Hit points added per level gained. Level 1 is SimConfig.PLAYER_MAX_HP, so a -## capped character has PLAYER_MAX_HP + 14 * this. -const HP_PER_LEVEL := 10 +## capped character has PLAYER_MAX_HP + 14 * this. Scaled with everything else +## -- see the note on SimConfig.PLAYER_MAX_HP. +const HP_PER_LEVEL := 100 ## Experience for the first level-up. The curve is tuned so one full clear of a ## depth-1 dungeon lands a little past this -- the first run should end with a diff --git a/src/sim/sim_world.gd b/src/sim/sim_world.gd index 8b392c1..3d9099b 100644 --- a/src/sim/sim_world.gd +++ b/src/sim/sim_world.gd @@ -746,6 +746,8 @@ func _damage_player(p: SimPlayer, amount: int) -> void: ## from the snapshot anyway. Death is still announced either way, because that ## is what the experience award is keyed on. func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void: + if e.def.indestructible: + return e.hp = maxi(e.hp - amount, 0) if not silent: events.append({"t": SimEvent.Type.ENEMY_HIT, "id": e.id, "dmg": amount, "hp": e.hp}) diff --git a/tests/unit/test_content.gd b/tests/unit/test_content.gd index b498bf5..c0a5a6f 100644 --- a/tests/unit/test_content.gd +++ b/tests/unit/test_content.gd @@ -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) diff --git a/tests/unit/test_regen.gd b/tests/unit/test_regen.gd index e202487..5b8c01a 100644 --- a/tests/unit/test_regen.gd +++ b/tests/unit/test_regen.gd @@ -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()