Files
transcience/tests/unit/test_telegraph.gd
T
claude e0c1e0d5c6
ci / verify (push) Successful in 49s
Stage 5: bosses that move, attacks that warn, and a second boss
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>
2026-09-06 16:32:12 +02:00

220 lines
7.4 KiB
GDScript

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)