e0c1e0d5c6
ci / verify (push) Successful in 49s
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>
227 lines
7.6 KiB
GDScript
227 lines
7.6 KiB
GDScript
extends GutTest
|
|
## The boss format is the thing the MVP has to prove is reusable, so these tests
|
|
## are written against [BossDef]/[BossPhase] rather than against the Warden --
|
|
## a second boss should pass them unchanged.
|
|
|
|
var world: SimWorld
|
|
|
|
|
|
func before_each() -> void:
|
|
world = SimWorld.new(3)
|
|
world.add_player(1, "tester")
|
|
world.players[1].pos = Vector2(0.0, 200.0)
|
|
|
|
|
|
func _spawn_warden() -> SimBoss:
|
|
return world.spawn_boss(Content.warden())
|
|
|
|
|
|
func test_phase_selection_follows_hp_fraction() -> void:
|
|
var def := Content.warden()
|
|
assert_eq(def.phase_index_for(1.0), 0)
|
|
assert_eq(def.phase_index_for(0.9), 0)
|
|
assert_eq(def.phase_index_for(0.5), 1)
|
|
assert_eq(def.phase_index_for(0.3), 2)
|
|
assert_eq(def.phase_index_for(0.05), 3)
|
|
|
|
|
|
func test_boss_advances_phase_when_damaged() -> void:
|
|
var boss := _spawn_warden()
|
|
world.step()
|
|
assert_eq(boss.phase_index, 0)
|
|
boss.hp = int(float(boss.def.max_hp) * 0.5)
|
|
world.step()
|
|
assert_eq(boss.phase_index, 1)
|
|
assert_eq(boss.phase_tick, 1, "entering a phase restarts its timeline")
|
|
|
|
|
|
func test_phase_change_is_announced() -> void:
|
|
var boss := _spawn_warden()
|
|
world.step()
|
|
world.drain_events()
|
|
boss.hp = int(float(boss.def.max_hp) * 0.3)
|
|
world.step()
|
|
var phases := world.events.filter(
|
|
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.BOSS_PHASE)
|
|
assert_eq(phases.size(), 1)
|
|
assert_eq(int(phases[0]["phase"]), 2)
|
|
|
|
|
|
func test_telegraph_holds_fire_before_the_phase_starts() -> void:
|
|
var boss := _spawn_warden()
|
|
var telegraph: int = boss.def.phases[0].telegraph_ticks
|
|
for _i in telegraph:
|
|
world.step()
|
|
assert_eq(world.pool.live_count, 0, "the telegraph window must be silent")
|
|
world.step()
|
|
assert_gt(world.pool.live_count, 0, "the pattern must start once telegraphed")
|
|
|
|
|
|
func test_the_boss_actually_fills_the_arena() -> void:
|
|
_spawn_warden()
|
|
for _i in 600:
|
|
world.step()
|
|
assert_gt(world.pool.live_count, 40,
|
|
"a boss phase should keep a real bullet field in the air")
|
|
|
|
|
|
func test_armour_multiplier_scales_incoming_damage() -> void:
|
|
var def := Content.warden()
|
|
def.phases[0].damage_taken_mult = 0.5
|
|
var boss := world.spawn_boss(def)
|
|
boss.pos = Vector2.ZERO
|
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 4.0, 60, 100,
|
|
SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT)
|
|
world.step()
|
|
assert_eq(boss.hp, def.max_hp - 50)
|
|
|
|
|
|
func test_boss_death_is_announced_once() -> void:
|
|
var boss := _spawn_warden()
|
|
boss.pos = Vector2.ZERO
|
|
boss.hp = 10
|
|
world.pool.spawn(Vector2.ZERO, Vector2.ZERO, 4.0, 60, 100,
|
|
SimConfig.TEAM_PLAYER, SimConfig.KIND_PLAYER_SHOT)
|
|
world.step()
|
|
assert_false(boss.alive)
|
|
var deaths := world.events.filter(
|
|
func(e: Dictionary) -> bool: return int(e["t"]) == SimEvent.Type.BOSS_DIED)
|
|
assert_eq(deaths.size(), 1)
|
|
|
|
|
|
## The point of the format: a boss built from scratch in a test, with no code
|
|
## anywhere in the simulation that knows about it, has to work.
|
|
func test_a_brand_new_boss_needs_no_engine_changes() -> void:
|
|
var ring := RingEmitter.new()
|
|
ring.interval = 20
|
|
ring.count = 6
|
|
|
|
var phase := BossPhase.new()
|
|
phase.name = "Only Phase"
|
|
phase.enter_at_hp_fraction = 1.0
|
|
phase.telegraph_ticks = 0
|
|
phase.loop_ticks = 100
|
|
phase.emitters = [ring]
|
|
|
|
var def := BossDef.new()
|
|
def.id = &"test_boss"
|
|
def.display_name = "Test Boss"
|
|
def.max_hp = 500
|
|
def.radius = 30.0
|
|
def.phases = [phase]
|
|
|
|
world.spawn_boss(def)
|
|
world.step()
|
|
assert_eq(world.pool.live_count, 6)
|
|
|
|
|
|
# --- The second boss --------------------------------------------------------
|
|
|
|
func test_the_cantor_is_registered_and_whole() -> void:
|
|
var b := Content.boss(Content.BOSS_CANTOR)
|
|
assert_eq(b.id, Content.BOSS_CANTOR)
|
|
assert_gt(b.max_hp, 0)
|
|
assert_gt(b.phases.size(), 1)
|
|
for phase in b.phases:
|
|
assert_gt(phase.emitters.size(), 0, "%s fires nothing" % phase.name)
|
|
assert_gt(b.loot.size(), 0, "a boss kill has to be worth something")
|
|
|
|
|
|
## Phases are picked by "the last one whose threshold still covers this hp
|
|
## fraction", so a list that is not sorted downwards silently skips phases.
|
|
func test_every_boss_lists_its_phases_from_full_health_downwards() -> void:
|
|
for id in Content.ALL_BOSSES:
|
|
var previous := 2.0
|
|
for phase in Content.boss(id).phases:
|
|
assert_lt(phase.enter_at_hp_fraction, previous,
|
|
"%s: %s is not below the phase before it" % [id, phase.name])
|
|
previous = phase.enter_at_hp_fraction
|
|
|
|
|
|
func test_every_boss_reaches_all_of_its_phases() -> void:
|
|
for id in Content.ALL_BOSSES:
|
|
var def := Content.boss(id)
|
|
var seen := {}
|
|
for step_index in 101:
|
|
seen[def.phase_index_for(float(step_index) / 100.0)] = true
|
|
assert_eq(seen.size(), def.phases.size(),
|
|
"%s has a phase that no health fraction selects" % id)
|
|
|
|
|
|
func test_there_is_a_sprite_for_every_boss() -> void:
|
|
for id in Content.ALL_BOSSES:
|
|
var def := Content.boss(id)
|
|
assert_lt(def.visual, Art.BOSS_IDLE_FRAMES.size(),
|
|
"%s has visual %d with no sprite" % [id, def.visual])
|
|
for n in Art.ACTOR_FRAMES:
|
|
var f := Art.frame(Art.boss_idle(def.visual), n)
|
|
assert_lte(f.end.x, float(Art.TILESET.get_width()))
|
|
assert_lte(f.end.y, float(Art.TILESET.get_height()))
|
|
|
|
|
|
func test_the_two_bosses_look_different() -> void:
|
|
assert_ne(Content.warden().visual, Content.cantor().visual)
|
|
|
|
|
|
## The Cantor's patterns assume the vault's barricades the way the Warden's
|
|
## assume the hall's pits, so which boss appears has to follow from which arena
|
|
## was stamped rather than being chosen separately.
|
|
## Every id in the registry has to resolve. The lists exist so nothing has to
|
|
## be hand-maintained in five places; this is what keeps them honest.
|
|
func test_the_registry_lists_resolve() -> void:
|
|
for id in Content.ALL_ENEMIES:
|
|
assert_eq(Content.enemy(id).id, id)
|
|
for id in Content.ALL_BOSSES:
|
|
assert_eq(Content.boss(id).id, id)
|
|
|
|
|
|
func test_each_arena_summons_its_own_boss() -> void:
|
|
assert_eq(Rooms.boss_for_arena(&"warden_hall"), Content.BOSS_WARDEN)
|
|
assert_eq(Rooms.boss_for_arena(&"choir_vault"), Content.BOSS_CANTOR)
|
|
|
|
|
|
## Seed parity picks the arena, and the boss follows it. Deliberately NOT the
|
|
## depth: depth is a dev flag nothing in play raises, so keying the arena to it
|
|
## left the second boss unreachable in an actual game.
|
|
func test_a_generated_dungeon_gets_the_boss_its_arena_belongs_to() -> void:
|
|
var even := MapGen.generate(1234, 1)
|
|
var odd := MapGen.generate(1235, 1)
|
|
assert_eq(StringName(even["boss_id"]), Content.BOSS_WARDEN)
|
|
assert_eq(StringName(odd["boss_id"]), Content.BOSS_CANTOR)
|
|
assert_not_null(Content.boss(StringName(even["boss_id"])))
|
|
assert_not_null(Content.boss(StringName(odd["boss_id"])))
|
|
|
|
|
|
## Both bosses have to actually turn up. A run picks its seed at random, so
|
|
## this is the check that neither is effectively unreachable.
|
|
func test_both_bosses_are_reachable_at_the_depth_people_play() -> void:
|
|
var seen := {}
|
|
for run in 40:
|
|
seen[StringName(MapGen.generate(run * 7919 + 3, 1)["boss_id"])] = true
|
|
for id in Content.ALL_BOSSES:
|
|
assert_true(seen.has(id), "%s never appears at depth 1" % id)
|
|
|
|
|
|
func test_an_instance_spawns_the_boss_its_map_asked_for() -> void:
|
|
var inst := Instance.make_dungeon(2, 4321, 1)
|
|
assert_eq(inst.boss_id, Content.BOSS_CANTOR)
|
|
assert_eq(inst.world.boss.def.id, Content.BOSS_CANTOR)
|
|
assert_true(inst.world.boss.room.has_point(inst.world.boss.pos),
|
|
"and it starts inside its own arena")
|
|
|
|
|
|
## The Cantor exists to prove the boss format stretched to movement and
|
|
## telegraphs. If it stopped using either, it would have stopped doing its job.
|
|
func test_the_cantor_actually_uses_both_new_mechanisms() -> void:
|
|
var def := Content.cantor()
|
|
var moves := false
|
|
var telegraphs := false
|
|
for phase in def.phases:
|
|
if phase.moves():
|
|
moves = true
|
|
for e in phase.emitters:
|
|
if e is TelegraphedStrikeEmitter:
|
|
telegraphs = true
|
|
assert_true(moves, "the Cantor should move")
|
|
assert_true(telegraphs, "and should telegraph")
|