Stage 5: bosses that move, attacks that warn, and a second boss
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>
This commit is contained in:
2026-09-06 16:32:12 +02:00
parent cb2c1e7840
commit e0c1e0d5c6
34 changed files with 1498 additions and 49 deletions
+1 -2
View File
@@ -75,8 +75,7 @@ func test_the_bullet_atlas_has_a_row_for_every_kind() -> void:
## One entry per EnemyDef.visual actually used by content, or an enemy draws as
## the wrong creature.
func test_there_is_a_sprite_for_every_enemy_visual() -> void:
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
for id in Content.ALL_ENEMIES:
var v := Content.enemy(id).visual
assert_lt(v, Art.ENEMY_IDLE.size(), "%s has visual %d with no sprite" % [id, v])
+111
View File
@@ -113,3 +113,114 @@ func test_a_brand_new_boss_needs_no_engine_changes() -> void:
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")
+286
View File
@@ -0,0 +1,286 @@
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")
+1
View File
@@ -0,0 +1 @@
uid://dreuvmvux87rj
+4 -3
View File
@@ -103,10 +103,11 @@ func test_bullet_speeds_stay_below_the_tunnelling_threshold() -> void:
func test_every_enemy_bullet_in_the_game_is_also_below_it() -> void:
var limit := MapGrid.TILE / SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for phase in Content.warden().phases:
emitters.append_array(phase.emitters)
for boss_id in Content.ALL_BOSSES:
for phase in Content.boss(boss_id).phases:
emitters.append_array(phase.emitters)
for e in emitters:
# Accelerating bullets reach their top speed at the end of their life.
var top: float = e.speed + maxf(e.accel, 0.0) * float(e.lifetime) * SimConfig.TICK_DELTA
+1 -2
View File
@@ -124,8 +124,7 @@ func test_hits_to_kill_a_player_is_what_it_was() -> void:
## misreported rather than rejected, which is exactly the kind of bug that
## survives a rescale unnoticed -- the old practice dummy was already past it.
func test_every_enemy_fits_the_health_field_the_wire_gives_it() -> void:
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET,
Content.ENEMY_STALKER, Content.ENEMY_DUMMY]:
for id in Content.ALL_ENEMIES:
assert_lte(Content.enemy(id).max_hp, 65535, "%s is too big for the wire" % id)
+4 -3
View File
@@ -90,10 +90,11 @@ func test_the_bullet_radius_covers_the_longest_shot_in_the_game() -> void:
var worst := SimConfig.MAX_BULLET_SPEED \
* float(SimConfig.PLAYER_BULLET_LIFETIME) * SimConfig.TICK_DELTA
var emitters: Array[BulletEmitter] = []
for id in [Content.ENEMY_DRIFTER, Content.ENEMY_TURRET, Content.ENEMY_STALKER]:
for id in Content.ALL_ENEMIES:
emitters.append_array(Content.enemy(id).emitters)
for phase in Content.warden().phases:
emitters.append_array(phase.emitters)
for boss_id in Content.ALL_BOSSES:
for phase in Content.boss(boss_id).phases:
emitters.append_array(phase.emitters)
for e in emitters:
worst = maxf(worst, e.speed * float(e.lifetime) * SimConfig.TICK_DELTA)
assert_gte(SimConfig.BULLET_INTEREST_RADIUS, worst + SimConfig.FOG_VIEW_RADIUS,
+28
View File
@@ -483,3 +483,31 @@ func test_an_unknown_upgrade_index_is_dropped_not_guessed() -> void:
b.put_u16(0)
var out := NetCodec.decode_upgrade_state(b.data_array)
assert_eq(out["offer"], [Upgrades.SNIPER] as Array[StringName])
func test_telegraph_events_round_trip() -> void:
var events: Array[Dictionary] = [
{"t": SimEvent.Type.TELEGRAPH, "pos": Vector2(-120.5, 64.25),
"r": 70.0, "ticks": 90},
]
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
assert_eq(out.size(), 1)
assert_almost_eq((out[0]["pos"] as Vector2).x, -120.5, 0.01)
assert_almost_eq((out[0]["pos"] as Vector2).y, 64.25, 0.01)
assert_almost_eq(float(out[0]["r"]), 70.0, 0.01)
assert_eq(int(out[0]["ticks"]), 90)
## A telegraph carries a fixed-width body like every other event. If its length
## were wrong the rest of the packet would decode as nonsense rather than fail.
func test_events_after_a_telegraph_still_decode() -> void:
var events: Array[Dictionary] = [
{"t": SimEvent.Type.TELEGRAPH, "pos": Vector2(10.0, 20.0), "r": 50.0, "ticks": 60},
{"t": SimEvent.Type.ENEMY_HIT, "id": 77, "dmg": 130, "hp": 900},
{"t": SimEvent.Type.BOSS_PHASE, "phase": 2},
]
var out: Array = NetCodec.decode_events(NetCodec.encode_events(1, events))["events"]
assert_eq(out.size(), 3)
assert_eq(int(out[1]["id"]), 77)
assert_eq(int(out[1]["hp"]), 900)
assert_eq(int(out[2]["phase"]), 2)
+219
View File
@@ -0,0 +1,219 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://bu6ayw438erq8