872922e9e2
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state() handed back the newest snapshot raw while players and enemies both went through the interpolator. The boss therefore stepped at the 20Hz snapshot rate instead of the frame rate. Invisible for as long as every boss stood still, and the first one that moved looked broken. It is interpolated now -- but not across an instance change, where the previous snapshot describes a different fight in a different room and lerping to it would fling the new boss across the map for a frame. The smaller cause was server-side: a CHASE boss corrects by the SIGN of its distance error, so at the standoff the sign flipped every tick and the boss vibrated a couple of pixels at 60Hz. It has a dead band now. The settings screen covers rebindable controls and volume, reachable from both the main menu and the in-game menu. Bindings are stored as physical keycodes -- following key position, the choice setup_input_map.gd already made -- and labelled back through the active layout so an AZERTY player reads the letter on the key their fingers are on. A rebind replaces every event on the action rather than the first, because an action that kept its alternates would still answer to the key you just moved away from. A key already in use is refused and the clash is named. Reset restores what the PROJECT shipped, captured once before anything overrides it -- captured later it would restore the last session's choice, which is the thing being undone. Effects play on an SFX bus created at runtime, so both sliders are real mixer settings rather than a number multiplied into every play() call. Worth recording: the test suite AND the smoke test both passed while a client logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null) does not mean "no default" -- it means the key is absent and no default was given, and the engine logs an error per action. Nothing caught it because the smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none of those. It surfaced from running the client and reading the output. smoke.sh now asserts no plain engine errors either, excluding by name the one line Godot prints on every clean exit, and reintroducing the bug makes it fail. One mutation caught nothing and should not have: the early return in linear_to_db_clamped was dead code, since the clamp beneath it already prevents negative infinity. Removed rather than left looking tested. check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics green, and a real client boots with zero engine errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
11 KiB
GDScript
312 lines
11 KiB
GDScript
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, Dungeons.VAULT)
|
|
assert_eq(inst.boss_id, Content.BOSS_CANTOR, "setup: the vault is the Cantor's")
|
|
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")
|
|
|
|
|
|
# --- Not looking jittery ----------------------------------------------------
|
|
|
|
## Once it has arrived it stays arrived. The correction is signed, so without a
|
|
## dead band the sign flips every tick at the standoff distance and the boss
|
|
## vibrates on the spot -- tiny in world units, unmistakable on screen.
|
|
func test_a_chasing_boss_settles_instead_of_buzzing() -> 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(300)
|
|
var settled := b.pos
|
|
var worst := 0.0
|
|
for _i in 120:
|
|
world.step()
|
|
worst = maxf(worst, b.pos.distance_to(settled))
|
|
assert_lt(worst, 0.001, "it moved %.2fpx after settling" % worst)
|
|
|
|
|
|
func test_the_dead_band_is_smaller_than_the_distance_it_guards() -> void:
|
|
# A band as wide as the standoff would mean the boss stops anywhere.
|
|
for phase in Content.cantor().phases:
|
|
if phase.move == BossPhase.Move.CHASE:
|
|
assert_lt(SimConfig.BOSS_CHASE_DEADBAND, phase.move_param * 0.25)
|