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