Files
claude e0c1e0d5c6
ci / verify (push) Successful in 49s
Stage 5: bosses that move, attacks that warn, and a second boss
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00

150 lines
5.7 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.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)