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")