4a98cf4b0e
ci / verify (push) Successful in 48s
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>
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, Dungeons.VAULT)
|
|
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: the vault is the Cantor's")
|
|
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")
|