Files
claude 4a98cf4b0e
ci / verify (push) Successful in 48s
The Cantor gets its own portal; bullets stop strobing
A dungeon now names the boss arena it ends in, and the arena decides the boss.
Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds.

That replaces the seed coin-flip from the last commit, which was the wrong
call. Which boss you are about to fight is the single thing a player decides
before walking into a dungeon, and rolling it for them makes that decision
unavailable. Only the Proving Grounds still leaves its arena open, because a
harness you re-enter every couple of minutes wants whichever fight comes up
first -- and it is the one place where "either will do" is true.

test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here:
adding a boss and forgetting to give it a way in is now a failing test rather
than a boss nobody meets. The smoke test asserts all three portals open over a
real socket -- bots pick theirs by account id, so a run exercises each.

Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather
than a shape change, so running it had every bullet on screen strobing through
a palette in unison, which with a few hundred in the air is exactly as hard to
look at as it sounds. Each bullet holds one frame and turns slowly instead,
offset by its own id so a ring of twenty does not rotate as one rigid wheel.
The rate is slow enough that nothing completes a full turn inside its own
lifetime, so it reads as drift rather than spin, and a test pins that against
the longest-lived bullet in the game. The renderer slices four textures at
startup now instead of thirty-two.

check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:36:06 +02:00

243 lines
8.2 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)
## The arena a dungeon names decides its boss, whatever the seed. Which boss you
## are about to fight is the one thing a player chooses before walking into a
## dungeon, so it must not be rolled for them.
func test_the_named_arena_decides_the_boss_whatever_the_seed() -> void:
for run in 12:
var seed_value := run * 7919 + 3
assert_eq(StringName(MapGen.generate(seed_value, 1, &"warden_hall")["boss_id"]),
Content.BOSS_WARDEN)
assert_eq(StringName(MapGen.generate(seed_value, 1, &"choir_vault")["boss_id"]),
Content.BOSS_CANTOR)
## Only the test harness leaves its arena open, and then the seed decides. Both
## fights still have to turn up there, or half the harness is unreachable.
func test_an_unnamed_arena_still_produces_both_fights() -> 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 with the arena left open" % id)
## The real check that a boss is reachable in play: some dungeon in the hub ends
## with it. Adding a boss and forgetting to give it a way in is exactly the
## mistake this catches.
func test_every_boss_has_a_dungeon_that_reaches_it() -> void:
var reachable := {}
for dungeon_id in Dungeons.ORDER:
var arena := Dungeons.get_def(dungeon_id).arena
if arena.is_empty():
continue # the harness rolls; covered above
reachable[Rooms.boss_for_arena(arena)] = true
for id in Content.ALL_BOSSES:
assert_true(reachable.has(id), "no hub portal leads to %s" % id)
func test_an_instance_spawns_the_boss_its_dungeon_asked_for() -> void:
for pair in [[Dungeons.STANDARD, Content.BOSS_WARDEN],
[Dungeons.VAULT, Content.BOSS_CANTOR]]:
var inst := Instance.make_dungeon(2, 4321, 1, pair[0])
assert_eq(inst.boss_id, pair[1], "%s should end with %s" % pair)
assert_eq(inst.world.boss.def.id, pair[1])
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")