Stage 5: bosses that move, attacks that warn, and a second boss
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>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-06 16:32:12 +02:00
1 parent cb2c1e7840
commit e0c1e0d5c6
34 files changed
+1498 -49

No files matched your search

+205 -1
View File
@@ -13,6 +13,18 @@ const ENEMY_TURRET := &"turret"
const ENEMY_STALKER := &"stalker"
const ENEMY_DUMMY := &"dummy"
const BOSS_WARDEN := &"warden"
const BOSS_CANTOR := &"cantor"
## Every id this file defines. Kept here so the export tool, the renderer and
## the tests all iterate the same list -- each of them used to hand-maintain
## its own copy, and the export tool had already gone stale and stopped writing
## the second boss.
const ALL_ENEMIES: Array[StringName] = [
ENEMY_DRIFTER, ENEMY_TURRET, ENEMY_STALKER, ENEMY_DUMMY,
]
const ALL_BOSSES: Array[StringName] = [
BOSS_WARDEN, BOSS_CANTOR,
]
## How often an ordinary enemy leaves a potion. "Rare" is the design brief: a
## dungeon run should be survivable on what it hands you, but never comfortably.
@@ -32,6 +44,7 @@ static func enemy(id: StringName) -> EnemyDef:
static func boss(id: StringName) -> BossDef:
match id:
BOSS_WARDEN: return warden()
BOSS_CANTOR: return cantor()
push_error("unknown boss id: %s" % id)
return warden()
@@ -159,8 +172,8 @@ static func warden() -> BossDef:
b.display_name = "Warden of the Fold"
b.max_hp = 36000
b.radius = 42.0
b.stationary = true
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 0
b.phases = [_warden_p1(), _warden_p2(), _warden_p3(), _warden_p4()]
# Guaranteed, and deliberately one of each visibility. The potion is shared,
# so a party still has something to divide up; the ration is instanced, so
@@ -337,3 +350,194 @@ static func _warden_p4() -> BossPhase:
p.emitters = [curve, walls, snipe]
return p
# --- The Cantor of the Vault ------------------------------------------------
## The second boss, and the one that proves the format stretched. The Warden is
## four phases of standing still and filling space; the Cantor walks.
##
## It fights in [method Rooms.choir_vault] -- tighter, with barricades you can
## see over but not shoot through -- so its patterns are about position rather
## than about density. Everything below is data: the simulation gained a
## movement mode and a telegraphing emitter, and no line anywhere knows this
## boss exists.
static func cantor() -> BossDef:
var b := BossDef.new()
b.id = BOSS_CANTOR
b.display_name = "Cantor of the Vault"
b.max_hp = 28000
b.radius = 36.0
b.spawn_pos = Vector2(0.0, -150.0)
b.visual = 1
b.phases = [_cantor_p1(), _cantor_p2(), _cantor_p3(), _cantor_p4()]
b.loot = [
LootDrop.make(Items.HEALTH_POTION, 1.0),
LootDrop.make(Items.WARDENS_RATION, 1.0, true),
]
return b
## Phase 1 -- introduces the telegraph and nothing else. Stands still, marks
## three spots, fills them. The whole phase exists to teach the marker.
static func _cantor_p1() -> BossPhase:
var p := BossPhase.new()
p.name = "Call to Prayer"
p.enter_at_hp_fraction = 1.0
p.loop_ticks = 360
p.telegraph_ticks = 60
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 180
strikes.warn_ticks = 80
strikes.strikes = 3
strikes.burst_count = 10
strikes.blast_radius = 66.0
strikes.speed = 130.0
strikes.radius = 7.0
strikes.damage = 130
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
var fan := AimedSpreadEmitter.new()
fan.start_tick = 40
fan.interval = 120
fan.count = 3
fan.spread_deg = 18.0
fan.speed = 190.0
fan.radius = 6.0
fan.damage = 110
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
p.emitters = [strikes, fan]
return p
## Phase 2 -- adds movement. It walks the four corners of the vault, dwelling
## at each, so the safe half of the room keeps changing while a spiral fills
## whatever it has left behind.
static func _cantor_p2() -> BossPhase:
var p := BossPhase.new()
p.name = "Processional"
p.enter_at_hp_fraction = 0.75
p.loop_ticks = 480
p.telegraph_ticks = 45
p.move = BossPhase.Move.WAYPOINTS
p.move_speed = 105.0
p.waypoint_dwell = 70
# Fractions of the arena, so the same walk works in a room of any size.
p.waypoints = [
Vector2(0.18, 0.2), Vector2(0.82, 0.2),
Vector2(0.82, 0.8), Vector2(0.18, 0.8),
]
var spiral := RingEmitter.new()
spiral.interval = 12
spiral.count = 3
spiral.spin_per_shot_deg = 27.0
spiral.speed = 125.0
spiral.radius = 7.0
spiral.damage = 120
spiral.lifetime = 400
spiral.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.start_tick = 60
strikes.interval = 200
strikes.warn_ticks = 90
strikes.strikes = 2
strikes.burst_count = 12
strikes.blast_radius = 70.0
strikes.pattern_seed = 17
strikes.speed = 145.0
strikes.radius = 7.0
strikes.damage = 140
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [spiral, strikes]
return p
## Phase 3 -- it comes for you. Holds a distance rather than closing, so the
## fight stays readable, and the aimed fire punishes standing still while the
## walls punish running in a straight line.
static func _cantor_p3() -> BossPhase:
var p := BossPhase.new()
p.name = "Antiphon"
p.enter_at_hp_fraction = 0.45
p.loop_ticks = 540
p.telegraph_ticks = 50
p.damage_taken_mult = 1.12
p.move = BossPhase.Move.CHASE
p.move_speed = 120.0
p.move_param = 220.0
var fan := AimedSpreadEmitter.new()
fan.interval = 90
fan.count = 5
fan.spread_deg = 34.0
fan.jitter_deg = 2.5
fan.speed = 200.0
fan.radius = 6.0
fan.damage = 120
fan.kind = SimConfig.KIND_NEEDLE
fan.muzzle_offset = 40.0
var walls := WallGapEmitter.new()
walls.start_tick = 70
walls.interval = 160
walls.direction = 1
walls.count = 16
walls.gap_width = 4
walls.gap_step = 6
walls.speed = 165.0
walls.radius = 8.0
walls.damage = 150
walls.lifetime = 300
walls.kind = SimConfig.KIND_HEAVY
p.emitters = [fan, walls]
return p
## Phase 4 -- it circles the vault while marking it. Movement and telegraphs at
## once, which is the point: both were added for this fight and this is where
## they have to work together.
static func _cantor_p4() -> BossPhase:
var p := BossPhase.new()
p.name = "Final Cadence"
p.enter_at_hp_fraction = 0.18
p.loop_ticks = 420
p.telegraph_ticks = 55
p.damage_taken_mult = 1.25
p.move = BossPhase.Move.ORBIT
p.move_speed = 150.0
p.move_param = 150.0
var ring := RingEmitter.new()
ring.interval = 40
ring.count = 12
ring.spin_per_shot_deg = 15.0
ring.speed = 140.0
ring.radius = 7.0
ring.damage = 130
ring.lifetime = 380
ring.muzzle_offset = 40.0
var strikes := TelegraphedStrikeEmitter.new()
strikes.interval = 150
strikes.warn_ticks = 70
strikes.strikes = 4
strikes.burst_count = 14
strikes.blast_radius = 72.0
strikes.pattern_seed = 91
strikes.speed = 150.0
strikes.radius = 7.0
strikes.damage = 150
strikes.lifetime = 240
strikes.kind = SimConfig.KIND_HEAVY
p.emitters = [ring, strikes]
return p
+10
View File
@@ -69,6 +69,16 @@ static func choir_vault() -> PackedStringArray:
])
## Which boss each arena belongs to. Kept beside the stamps because the pairing
## is a content decision, not a generation one: the Cantor's patterns assume
## the vault's barricades the way the Warden's assume the hall's pits.
static func boss_for_arena(arena: StringName) -> StringName:
match arena:
&"warden_hall": return Content.BOSS_WARDEN
&"choir_vault": return Content.BOSS_CANTOR
return Content.BOSS_WARDEN
static func size_of(stamp: PackedStringArray) -> Vector2i:
if stamp.is_empty():
return Vector2i.ZERO