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>
287 lines
9.6 KiB
GDScript
287 lines
9.6 KiB
GDScript
extends GutTest
|
|
## Bosses that move. Movement is a property of the PHASE, so all of this is
|
|
## driven by building a BossPhase and stepping the world -- if any of it needed
|
|
## a per-boss branch in SimWorld, the boss format would have stopped being data.
|
|
|
|
var world: SimWorld
|
|
var boss: SimBoss
|
|
|
|
const ROOM := Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0))
|
|
|
|
|
|
func before_each() -> void:
|
|
var map := MapGrid.new(60, 40, MapGrid.Kind.WALL)
|
|
map.fill_rect(Rect2i(1, 1, 58, 38), MapGrid.Kind.FLOOR)
|
|
map.centre_on_origin()
|
|
world = SimWorld.new(3)
|
|
world.set_map(map)
|
|
|
|
|
|
func _with_phase(phase: BossPhase, at := Vector2.ZERO) -> SimBoss:
|
|
var def := BossDef.new()
|
|
def.id = &"test_boss"
|
|
def.max_hp = 1000
|
|
def.radius = 30.0
|
|
def.phases = [phase]
|
|
boss = world.spawn_boss(def)
|
|
boss.pos = at
|
|
boss.room = ROOM
|
|
return boss
|
|
|
|
|
|
func _static_phase() -> BossPhase:
|
|
var p := BossPhase.new()
|
|
p.enter_at_hp_fraction = 1.0
|
|
p.telegraph_ticks = 0
|
|
p.loop_ticks = 600
|
|
return p
|
|
|
|
|
|
func _step(n: int) -> void:
|
|
for _i in n:
|
|
world.step()
|
|
|
|
|
|
# --- The derived "does it move" flag ----------------------------------------
|
|
|
|
func test_a_phase_with_no_mode_or_no_speed_does_not_move() -> void:
|
|
var p := _static_phase()
|
|
assert_false(p.moves())
|
|
p.move = BossPhase.Move.CHASE
|
|
assert_false(p.moves(), "a mode with no speed is still standing still")
|
|
p.move_speed = 60.0
|
|
assert_true(p.moves())
|
|
|
|
|
|
## Derived from the phases rather than stored, so a boss cannot claim to be
|
|
## stationary while one of its phases walks around.
|
|
func test_a_boss_moves_if_any_of_its_phases_does() -> void:
|
|
assert_false(Content.warden().moves(), "the Warden still stands still")
|
|
assert_true(Content.cantor().moves(), "the Cantor does not")
|
|
|
|
|
|
func test_a_static_phase_leaves_the_boss_exactly_where_it_was() -> void:
|
|
var b := _with_phase(_static_phase(), Vector2(50.0, 20.0))
|
|
_step(120)
|
|
assert_eq(b.pos, Vector2(50.0, 20.0))
|
|
|
|
|
|
# --- Waypoints ---------------------------------------------------------------
|
|
|
|
func _waypoint_phase() -> BossPhase:
|
|
var p := _static_phase()
|
|
p.move = BossPhase.Move.WAYPOINTS
|
|
p.move_speed = 200.0
|
|
p.waypoint_dwell = 30
|
|
p.waypoints = [Vector2(0.0, 0.0), Vector2(1.0, 0.0)]
|
|
return p
|
|
|
|
|
|
func test_a_waypoint_boss_walks_to_its_first_point() -> void:
|
|
var b := _with_phase(_waypoint_phase(), ROOM.get_center())
|
|
var corner := ROOM.position
|
|
var before := b.pos.distance_to(corner)
|
|
_step(30)
|
|
assert_lt(b.pos.distance_to(corner), before, "it should be closing")
|
|
|
|
|
|
func test_it_arrives_dwells_and_moves_on() -> void:
|
|
var phase := _waypoint_phase()
|
|
var b := _with_phase(phase, ROOM.position)
|
|
_step(2)
|
|
assert_eq(b.waypoint_index, 1, "standing on the first point advances it")
|
|
var held := b.pos
|
|
_step(phase.waypoint_dwell - 4)
|
|
assert_eq(b.pos, held, "and it waits there rather than setting off at once")
|
|
_step(60)
|
|
assert_ne(b.pos, held, "then it goes")
|
|
|
|
|
|
## Fractions of the arena, not absolute positions -- the Warden's hall and the
|
|
## Choir Vault are different sizes, and one phase has to work in either.
|
|
func test_waypoints_are_fractions_of_the_room() -> void:
|
|
var phase := _waypoint_phase()
|
|
phase.waypoints = [Vector2(0.5, 0.5)]
|
|
var b := _with_phase(phase, ROOM.position)
|
|
_step(200)
|
|
assert_almost_eq(b.pos.x, ROOM.get_center().x, 4.0)
|
|
assert_almost_eq(b.pos.y, ROOM.get_center().y, 4.0)
|
|
|
|
|
|
func test_a_waypoint_phase_with_no_points_stands_still() -> void:
|
|
var phase := _waypoint_phase()
|
|
phase.waypoints = []
|
|
var b := _with_phase(phase, Vector2(10.0, 10.0))
|
|
_step(60)
|
|
assert_eq(b.pos, Vector2(10.0, 10.0))
|
|
|
|
|
|
# --- Chase -------------------------------------------------------------------
|
|
|
|
func _chase_phase(standoff: float) -> BossPhase:
|
|
var p := _static_phase()
|
|
p.move = BossPhase.Move.CHASE
|
|
p.move_speed = 180.0
|
|
p.move_param = standoff
|
|
return p
|
|
|
|
|
|
func test_it_closes_when_you_are_far_away() -> void:
|
|
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
|
var p := world.add_player(1, "bait")
|
|
p.pos = Vector2(250.0, 0.0)
|
|
var before := b.pos.distance_to(p.pos)
|
|
_step(60)
|
|
assert_lt(b.pos.distance_to(p.pos), before)
|
|
|
|
|
|
## Backs off rather than piling onto you. A boss standing on top of a player is
|
|
## a boss whose bullets cannot be read, which is the one thing this genre
|
|
## cannot afford.
|
|
func test_it_backs_off_when_you_get_too_close() -> void:
|
|
var b := _with_phase(_chase_phase(200.0), Vector2.ZERO)
|
|
var p := world.add_player(1, "bait")
|
|
p.pos = Vector2(20.0, 0.0)
|
|
_step(60)
|
|
assert_gt(b.pos.distance_to(p.pos), 20.0)
|
|
|
|
|
|
func test_it_settles_at_the_distance_it_was_given() -> void:
|
|
var b := _with_phase(_chase_phase(150.0), Vector2(-250.0, 0.0))
|
|
var p := world.add_player(1, "bait")
|
|
p.pos = Vector2(100.0, 0.0)
|
|
_step(240)
|
|
assert_almost_eq(b.pos.distance_to(p.pos), 150.0, 12.0)
|
|
|
|
|
|
func test_it_stands_still_with_nobody_to_chase() -> void:
|
|
var b := _with_phase(_chase_phase(150.0), Vector2(40.0, 0.0))
|
|
_step(60)
|
|
assert_eq(b.pos, Vector2(40.0, 0.0))
|
|
|
|
|
|
# --- Orbit -------------------------------------------------------------------
|
|
|
|
func _orbit_phase() -> BossPhase:
|
|
var p := _static_phase()
|
|
p.move = BossPhase.Move.ORBIT
|
|
p.move_speed = 160.0
|
|
p.move_param = 120.0
|
|
return p
|
|
|
|
|
|
## ORBIT computes an absolute destination, so without the shared speed clamp it
|
|
## would snap onto its circle on the very first tick.
|
|
func test_orbiting_never_teleports_onto_the_circle() -> void:
|
|
var phase := _orbit_phase()
|
|
var b := _with_phase(phase, ROOM.get_center() + Vector2(280.0, 0.0))
|
|
var before := b.pos
|
|
world.step()
|
|
assert_lte(b.pos.distance_to(before), phase.move_speed * SimConfig.TICK_DELTA + 0.5,
|
|
"one tick may move it at most one tick's worth")
|
|
|
|
|
|
func test_orbiting_ends_up_on_the_circle_and_keeps_going() -> void:
|
|
var phase := _orbit_phase()
|
|
var b := _with_phase(phase, ROOM.get_center())
|
|
_step(300)
|
|
var centre := ROOM.get_center()
|
|
assert_almost_eq(b.pos.distance_to(centre), phase.move_param, 25.0)
|
|
var somewhere := b.pos
|
|
_step(90)
|
|
assert_gt(b.pos.distance_to(somewhere), 20.0, "and it is still travelling")
|
|
|
|
|
|
# --- The invariants movement had to not break --------------------------------
|
|
|
|
## Boss rooms deliberately do not lock: a player can always walk out. That only
|
|
## works as an escape if the boss cannot follow.
|
|
func test_a_moving_boss_never_leaves_its_arena() -> void:
|
|
var phase := _chase_phase(0.0)
|
|
phase.move_speed = 400.0
|
|
var b := _with_phase(phase, ROOM.get_center())
|
|
var p := world.add_player(1, "bait")
|
|
for step_index in 400:
|
|
# Drag the bait right out of the room and around the map.
|
|
p.pos = Vector2(900.0, 500.0).rotated(float(step_index) * 0.05)
|
|
world.step()
|
|
assert_true(ROOM.has_point(b.pos) or ROOM.abs().grow(1.0).has_point(b.pos),
|
|
"the boss left its arena at %s" % b.pos)
|
|
|
|
|
|
## A single pillar proves nothing -- the boss would end up at its quarry either
|
|
## way, and only the final position was ever checked. A wall it cannot go round
|
|
## is the test: if movement ignores geometry the boss simply appears on the far
|
|
## side of it.
|
|
func test_a_moving_boss_does_not_walk_through_geometry() -> void:
|
|
var wall_x := world.map.to_tile(Vector2.ZERO).x
|
|
for ty in range(world.map.to_tile(Vector2(0.0, -260.0)).y,
|
|
world.map.to_tile(Vector2(0.0, 260.0)).y + 1):
|
|
world.map.set_tile(wall_x, ty, MapGrid.Kind.WALL)
|
|
|
|
var b := _with_phase(_chase_phase(0.0), Vector2(-200.0, 0.0))
|
|
var p := world.add_player(1, "bait")
|
|
p.pos = Vector2(200.0, 0.0)
|
|
for _i in 300:
|
|
world.step()
|
|
assert_false(world.map.circle_blocked(b.pos, 2.0),
|
|
"the boss stepped inside solid geometry at %s" % b.pos)
|
|
assert_lt(b.pos.x, 0.0,
|
|
"the wall spans the arena, so the boss must still be on its own side")
|
|
|
|
|
|
## The replica draws bosses from snapshots. If it moved one itself, the drawn
|
|
## boss and the authoritative one would drift apart with nothing to correct it.
|
|
func test_a_replica_never_moves_a_boss() -> void:
|
|
var replica := SimWorld.new(3)
|
|
replica.authoritative = false
|
|
var def := BossDef.new()
|
|
def.max_hp = 1000
|
|
def.radius = 30.0
|
|
def.phases = [_waypoint_phase()]
|
|
var b := replica.spawn_boss(def)
|
|
b.pos = Vector2(77.0, -33.0)
|
|
b.room = ROOM
|
|
for _i in 200:
|
|
replica.step()
|
|
assert_eq(b.pos, Vector2(77.0, -33.0))
|
|
|
|
|
|
# --- The real fight ---------------------------------------------------------
|
|
|
|
## Drives the actual Cantor through every phase in its own arena. Nothing here
|
|
## asserts a specific pattern -- the point is that a boss which moves, walks a
|
|
## circuit, chases, orbits and telegraphs runs for thousands of ticks without
|
|
## leaving its room, standing in a wall, or firing nothing.
|
|
func test_the_cantor_survives_its_own_fight() -> void:
|
|
var inst := Instance.make_dungeon(2, 9183, 1)
|
|
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: an odd seed is the vault")
|
|
var b := inst.world.boss
|
|
var bait := inst.world.add_player(1, "bait")
|
|
bait.pos = b.pos + Vector2(180.0, 0.0)
|
|
bait.spawn_grace = 1000000 # watching, not fighting
|
|
|
|
var phases_seen := {}
|
|
var telegraphs := 0
|
|
var full := b.def.max_hp
|
|
for stage in b.def.phases.size():
|
|
# Set health to each phase's own threshold rather than to fractions
|
|
# picked by hand -- guessing them missed the last phase entirely, and
|
|
# would go stale the moment the fight was retuned.
|
|
b.hp = maxi(roundi(float(full) * b.def.phases[stage].enter_at_hp_fraction), 1)
|
|
for _i in 600:
|
|
inst.step()
|
|
phases_seen[b.phase_index] = true
|
|
assert_true(b.room.grow(1.0).has_point(b.pos),
|
|
"the Cantor left its arena at %s" % b.pos)
|
|
assert_false(inst.world.map.circle_blocked(b.pos, 2.0),
|
|
"the Cantor stood inside geometry at %s" % b.pos)
|
|
for ev in inst.world.events:
|
|
if int(ev["t"]) == SimEvent.Type.TELEGRAPH:
|
|
telegraphs += 1
|
|
inst.world.drain_events()
|
|
|
|
assert_eq(phases_seen.size(), b.def.phases.size(), "every phase ran")
|
|
assert_gt(telegraphs, 0, "and it warned before striking at least once")
|
|
assert_gt(inst.world.pool.live_count, 0, "and it is actually shooting")
|