extends GutTest ## Telegraphed strikes: the warning, the strike, and the promise that the two ## land in the same place. ## ## The emitter is stateless -- emitters are shared resources and two bosses of ## the same kind must not stomp each other -- so the strike positions are ## derived from the volley number rather than rolled and remembered. Most of ## what is worth testing here follows from that. const BOUNDS := Rect2(Vector2(-400.0, -300.0), Vector2(800.0, 600.0)) var emitter: TelegraphedStrikeEmitter var ctx: EmitContext var pool: BulletPool func before_each() -> void: emitter = TelegraphedStrikeEmitter.new() emitter.interval = 120 emitter.warn_ticks = 60 emitter.strikes = 3 emitter.burst_count = 8 emitter.blast_radius = 60.0 emitter.speed = 140.0 emitter.damage = 100 pool = BulletPool.new() ctx = EmitContext.new() ctx.pool = pool ctx.bounds = BOUNDS ctx.rng = RandomNumberGenerator.new() func _run(local_tick: int) -> void: ctx.local_tick = local_tick ctx.shot_index = emitter.shot_index_at(local_tick) if emitter.should_fire(local_tick): emitter.fire(ctx) func _telegraphs() -> Array[Dictionary]: var out: Array[Dictionary] = [] for ev in ctx.events: if int(ev["t"]) == SimEvent.Type.TELEGRAPH: out.append(ev) return out # --- The two moments --------------------------------------------------------- func test_a_volley_announces_before_it_fires() -> void: _run(0) assert_eq(_telegraphs().size(), emitter.strikes, "one warning per strike") assert_eq(pool.live_count, 0, "and not a single bullet yet") func test_the_burst_lands_when_the_warning_runs_out() -> void: _run(0) _run(emitter.warn_ticks) assert_eq(pool.live_count, emitter.strikes * emitter.burst_count) func test_nothing_happens_between_the_warning_and_the_strike() -> void: for t in range(0, emitter.warn_ticks): _run(t) assert_eq(pool.live_count, 0) assert_eq(_telegraphs().size(), emitter.strikes, "and the warning is announced once, not every tick") func test_the_warning_carries_where_how_big_and_how_long() -> void: _run(0) for ev in _telegraphs(): assert_eq(float(ev["r"]), emitter.blast_radius) assert_eq(int(ev["ticks"]), emitter.warn_ticks) assert_true(BOUNDS.has_point(ev["pos"])) ## The whole reason the emitter is written the way it is: the warning and the ## burst are computed at different ticks with nothing stored in between, and ## they have to agree. A marker that lied about where the strike would land ## would be worse than no marker. func test_the_burst_lands_where_the_warning_said_it_would() -> void: _run(0) var promised: Array[Vector2] = [] for ev in _telegraphs(): promised.append(ev["pos"]) _run(emitter.warn_ticks) for i in pool.high_water: if pool.alive[i] == 0: continue var nearest := INF for spot in promised: nearest = minf(nearest, spot.distance_to(pool.pos[i])) assert_lt(nearest, emitter.blast_radius, "a bullet appeared %.0f from any marked spot" % nearest) func test_strike_points_are_a_pure_function_of_the_volley() -> void: for volley in 20: for index in emitter.strikes: assert_eq(emitter.strike_point(volley, index, BOUNDS), emitter.strike_point(volley, index, BOUNDS)) func test_consecutive_volleys_pick_different_places() -> void: var moved := 0 for volley in 30: if emitter.strike_point(volley, 0, BOUNDS) \ != emitter.strike_point(volley + 1, 0, BOUNDS): moved += 1 assert_gt(moved, 25, "a strike that always lands in one place is a wall") func test_the_strikes_in_one_volley_are_not_all_the_same_spot() -> void: var distinct := {} for index in emitter.strikes: distinct[emitter.strike_point(3, index, BOUNDS)] = true assert_eq(distinct.size(), emitter.strikes) ## Two strike emitters in one phase would otherwise derive identical points and ## stack every burst on top of itself. func test_the_pattern_seed_separates_two_emitters() -> void: var other := TelegraphedStrikeEmitter.new() other.pattern_seed = 91 var same := 0 for volley in 20: if emitter.strike_point(volley, 0, BOUNDS) == other.strike_point(volley, 0, BOUNDS): same += 1 assert_lt(same, 3) ## Half a burst spent against a wall is half a burst the player never had to ## dodge. func test_strikes_stay_clear_of_the_arena_edge() -> void: var inner := BOUNDS.grow(-emitter.margin) for volley in 50: for index in emitter.strikes: assert_true(inner.has_point(emitter.strike_point(volley, index, BOUNDS))) ## A tiny room cannot be shrunk by the margin without inverting. Falling back to ## the whole room beats emitting at a negative-size rectangle's corner. func test_a_room_smaller_than_the_margin_still_produces_points_inside_it() -> void: var tiny := Rect2(Vector2(-20.0, -20.0), Vector2(40.0, 40.0)) for volley in 20: assert_true(tiny.has_point(emitter.strike_point(volley, 0, tiny))) # --- Wired into a real fight ------------------------------------------------- func test_a_boss_running_the_emitter_announces_through_the_world() -> void: var world := SimWorld.new(5) var def := BossDef.new() def.max_hp = 5000 def.radius = 30.0 var phase := BossPhase.new() phase.enter_at_hp_fraction = 1.0 phase.telegraph_ticks = 0 phase.loop_ticks = 600 phase.emitters = [emitter] def.phases = [phase] var boss := world.spawn_boss(def) boss.room = Rect2(Vector2(-300.0, -200.0), Vector2(600.0, 400.0)) world.step() var announced := 0 for ev in world.events: if int(ev["t"]) == SimEvent.Type.TELEGRAPH: announced += 1 assert_eq(announced, emitter.strikes, "the emitter's warning has to reach the world's event list") ## Every strike emitter in the game has to warn inside its own cycle, or a ## volley lands after the next one has already been announced and the markers ## stop meaning anything. func test_every_authored_strike_warns_before_its_next_volley() -> void: var phases: Array[BossPhase] = [] phases.append_array(Content.warden().phases) phases.append_array(Content.cantor().phases) var found := 0 for phase in phases: for e in phase.emitters: if e is TelegraphedStrikeEmitter: found += 1 assert_lt((e as TelegraphedStrikeEmitter).warn_ticks, e.interval, "a strike must land before the next volley is called") assert_gt(found, 0, "setup: some boss should actually use these") # --- The client's side ------------------------------------------------------ ## Warnings expire on the SERVER's clock, not on wall time: a frame-rate dip ## must not leave a marker sitting over ground that was struck seconds ago. func test_the_client_drops_a_warning_when_its_attack_has_landed() -> void: var client: ClientRuntime = autofree(ClientRuntime.new()) client.server_tick_est = 100 client.telegraphs = [ {"pos": Vector2.ZERO, "r": 60.0, "ticks": 60, "until": 130}, {"pos": Vector2(10.0, 0.0), "r": 60.0, "ticks": 60, "until": 160}, ] client._expire_telegraphs() assert_eq(client.telegraphs.size(), 2, "neither has landed yet") client.server_tick_est = 140 client._expire_telegraphs() assert_eq(client.telegraphs.size(), 1) client.server_tick_est = 200 client._expire_telegraphs() assert_eq(client.telegraphs.size(), 0) ## Entering an instance has to clear them, or a warning from the dungeon you ## just left would hang over the hub floor. func test_arriving_somewhere_clears_the_old_warnings() -> void: var client: ClientRuntime = autofree(ClientRuntime.new()) client.telegraphs = [{"pos": Vector2.ZERO, "r": 60.0, "ticks": 60, "until": 999}] client.on_enter_instance(7, Protocol.InstanceKind.LOBBY, 0, "", Vector2.ZERO, 20, 20, PackedByteArray(), "", Vector2.ZERO) assert_eq(client.telegraphs.size(), 0)