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) ## Seed parity picks the arena, and the boss follows it. Deliberately NOT the ## depth: depth is a dev flag nothing in play raises, so keying the arena to it ## left the second boss unreachable in an actual game. func test_a_generated_dungeon_gets_the_boss_its_arena_belongs_to() -> void: var even := MapGen.generate(1234, 1) var odd := MapGen.generate(1235, 1) assert_eq(StringName(even["boss_id"]), Content.BOSS_WARDEN) assert_eq(StringName(odd["boss_id"]), Content.BOSS_CANTOR) assert_not_null(Content.boss(StringName(even["boss_id"]))) assert_not_null(Content.boss(StringName(odd["boss_id"]))) ## Both bosses have to actually turn up. A run picks its seed at random, so ## this is the check that neither is effectively unreachable. func test_both_bosses_are_reachable_at_the_depth_people_play() -> 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 at depth 1" % id) func test_an_instance_spawns_the_boss_its_map_asked_for() -> void: var inst := Instance.make_dungeon(2, 4321, 1) assert_eq(inst.boss_id, Content.BOSS_CANTOR) assert_eq(inst.world.boss.def.id, Content.BOSS_CANTOR) 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")