feat(client): 3D locomotion sandbox — character walks the live Gauntlet (T-1088)

New SR_LIVE sandbox scene: CharacterVisual composited in a 3D greybox world
derived from server snapshots. Per-leg constant-velocity interpolation keyed
to the stance throttle, 'server feet / client eyes' facing (wire octant while
moving, client aim octant idle), cadence-synced gait state machine on
AnimationPlayer custom blends, D-148 orthographic follow camera (-30deg
default, T-cycle presets), sim-space grid shader, camera-side wall cutaway,
accumulating never-evict tile store with four-state visibility tint.

Additive seams only: InputMapper.facing_angle_provider (2D path unchanged),
CharacterVisual.play_animation blend_time param + get_animation_player().
Visual harness gains per-scenario scene field + SR_AUTOPILOT input scripting.
210 new gdUnit assertions across five suites; verified live (230/230 total,
clean smoke, screenshot at .cache/screenshots/locomotion_idle_live.png).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 13:23:16 +02:00
co-authored by Claude Fable 5
parent ce1811b5f9
commit a7801a942a
21 changed files with 3555 additions and 8 deletions
+436
View File
@@ -0,0 +1,436 @@
## LocomotionAnim gait machine tests (T-1088 design §6, §10.4).
##
## Two layers:
## 1. END-TO-END CLIP GUARD — every SandboxConstants.GAIT_CLIP cell must exist in a
## real headless CharacterVisual's get_animation_list(). play_animation() is a
## case-sensitive exact-name search that fails with only a warning, so a typo'd
## or renamed clip is a SILENT miss in production — this test is the tripwire,
## end-to-end against the imported GLB, not against a constant copy.
## 2. State-machine logic on stubs — edge-triggered transitions, the §6.3 blend
## table, §6.2 cadence sync, phase preservation + skip_phase_seek, teleport
## hard reset, and the gait_changed (Q-063) signal.
class_name TestGaitTable
extends GdUnitTestSuite
const EPS := 0.000001
# =============================================================================
# Stubs — duck-typed against the §4.0 rig getters and §6.3 CharacterVisual API
# =============================================================================
class StubRig:
extends RefCounted
var stance: String = "Walk"
var is_moving: bool = false
var current_speed: float = 0.0
class StubAnimPlayer:
extends RefCounted
## Clip lengths mirror the verified import dump (design-input §2.2) — values only
## matter for phase arithmetic, not for clip existence (layer 1 covers that).
const LENGTHS := {
"Idle": 2.5, "Walk": 1.33, "Walk_Formal": 1.33,
"Sprint": 0.67, "Crouch_Idle": 2.93, "Crouch_Fwd": 2.0,
}
var speed_scale: float = 1.0
var current_animation: String = ""
var current_animation_position: float = 0.0
var current_animation_length: float = 0.0
var seeks: Array = [] # [seconds, update] per seek() call
func get_animation(clip_name: StringName) -> Animation:
var anim := Animation.new()
anim.length = float(LENGTHS.get(String(clip_name), 1.0))
return anim
func seek(seconds: float, update: bool = false, _update_only: bool = false) -> void:
seeks.append([seconds, update])
current_animation_position = seconds
class StubVisual:
extends RefCounted
var player := StubAnimPlayer.new()
var plays: Array = [] # [{"name": String, "blend": float}] per play_animation call
func play_animation(anim_name: String, blend_time: float = -1.0) -> void:
plays.append({"name": anim_name, "blend": blend_time})
player.current_animation = anim_name
player.current_animation_length = float(StubAnimPlayer.LENGTHS.get(anim_name, 1.0))
player.current_animation_position = 0.0
func get_animation_player() -> StubAnimPlayer:
return player
## Machine wired to fresh stubs; returns [anim, rig, visual].
func _make_machine() -> Array:
var rig := StubRig.new()
var visual := StubVisual.new()
var anim := LocomotionAnim.new()
anim.setup(rig, visual)
return [anim, rig, visual]
func _last_play(visual: StubVisual) -> Dictionary:
return {} if visual.plays.is_empty() else visual.plays[-1]
# =============================================================================
# 1. End-to-end clip guard — real headless CharacterVisual (§6.1)
# =============================================================================
func test_every_gait_cell_exists_in_imported_animation_list() -> void:
# §2.1 order is load-bearing: .new() -> add_child() -> load_descriptor().
# In-tree first, because _ready() loads the toon/outline shaders. A default
# descriptor is enough — only the mandatory skeleton + animation library matter.
var visual: CharacterVisual = auto_free(CharacterVisual.new())
add_child(visual)
visual.load_descriptor(CharacterVisualDescriptor.new())
var player: AnimationPlayer = visual.get_animation_player()
assert_object(player).override_failure_message(
"get_animation_player() must return the AnimPlayer after load_descriptor()"
+ " — null means the skeleton or ual_standard.glb failed to load"
).is_not_null()
if player == null:
return
var clips := player.get_animation_list()
assert_bool(clips.is_empty()).override_failure_message(
"imported animation list is empty — ual_standard.glb library copy failed"
).is_false()
for stance: String in SandboxConstants.GAIT_CLIP:
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
for cell: String in row:
var clip := String(row[cell])
assert_bool(clips.has(clip)).override_failure_message(
(
"GAIT_CLIP[%s][%s] = '%s' not in the imported animation list —"
+ " play_animation() would miss SILENTLY (case-sensitive; library"
+ " '' bare names, '_Loop' stripped by the importer). List: %s"
) % [stance, cell, clip, clips]
).is_true()
func test_gait_table_covers_all_four_stances() -> void:
# The wire stance enum (D-053/D-055): every variant must have both cells.
for stance: String in ["Sprint", "Walk", "Careful", "Crouch"]:
assert_bool(SandboxConstants.GAIT_CLIP.has(stance)).override_failure_message(
"GAIT_CLIP missing wire stance '%s'" % stance
).is_true()
var row: Dictionary = SandboxConstants.GAIT_CLIP[stance]
assert_bool(row.has("idle") and row.has("moving")).override_failure_message(
"GAIT_CLIP[%s] must have both 'idle' and 'moving' cells" % stance
).is_true()
# =============================================================================
# 2. Pure gait() lookup (§6.1)
# =============================================================================
func test_gait_returns_table_cells() -> void:
assert_str(String(LocomotionAnim.gait("Walk", false))).is_equal("Idle")
assert_str(String(LocomotionAnim.gait("Walk", true))).is_equal("Walk")
assert_str(String(LocomotionAnim.gait("Careful", true))).is_equal("Walk_Formal")
assert_str(String(LocomotionAnim.gait("Sprint", true))).is_equal("Sprint")
assert_str(String(LocomotionAnim.gait("Crouch", false))).is_equal("Crouch_Idle")
assert_str(String(LocomotionAnim.gait("Crouch", true))).is_equal("Crouch_Fwd")
func test_gait_unknown_stance_falls_back_to_walk_row() -> void:
# Mirrors the wire default (player_stance serde-defaults to Walk).
assert_str(String(LocomotionAnim.gait("Prone", true))).is_equal("Walk")
assert_str(String(LocomotionAnim.gait("Prone", false))).is_equal("Idle")
# =============================================================================
# 3. Blend table (§6.3) — pure blend_for
# =============================================================================
func test_blend_idle_to_gait() -> void:
var b := LocomotionAnim.blend_for(&"Idle", &"Walk", false, true)
assert_float(b).is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS)
func test_blend_gait_to_idle() -> void:
var b := LocomotionAnim.blend_for(&"Walk", &"Idle", true, false)
assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS)
func test_blend_gait_to_gait() -> void:
var b := LocomotionAnim.blend_for(&"Walk", &"Sprint", true, true)
assert_float(b).is_equal_approx(SandboxConstants.BLEND["gait_to_gait"], EPS)
func test_blend_crouch_overrides_all_edges() -> void:
# "<->Crouch_*" takes the crouch blend regardless of the idle/gait edge kind.
var crouch: float = SandboxConstants.BLEND["crouch"]
assert_float(LocomotionAnim.blend_for(&"Walk", &"Crouch_Fwd", true, true)) \
.is_equal_approx(crouch, EPS)
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Walk", true, true)) \
.is_equal_approx(crouch, EPS)
assert_float(LocomotionAnim.blend_for(&"Idle", &"Crouch_Idle", false, false)) \
.is_equal_approx(crouch, EPS)
assert_float(LocomotionAnim.blend_for(&"Crouch_Idle", &"Crouch_Fwd", false, true)) \
.is_equal_approx(crouch, EPS)
assert_float(LocomotionAnim.blend_for(&"Crouch_Fwd", &"Crouch_Idle", true, false)) \
.is_equal_approx(crouch, EPS)
# =============================================================================
# 4. Edge-triggered transitions on the stubbed player
# =============================================================================
func test_first_update_plays_idle_with_hard_cut() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var visual: StubVisual = m[2]
anim.update(0.016)
assert_int(visual.plays.size()).is_equal(1)
assert_str(_last_play(visual)["name"]).is_equal("Idle")
# First-ever play: -1.0 rides play_animation's default hard-cut path.
assert_float(_last_play(visual)["blend"]).is_equal_approx(-1.0, EPS)
func test_unchanged_state_never_retriggers_play() -> void:
# Loops never restart mid-cycle: same state across frames = exactly one play call.
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var visual: StubVisual = m[2]
for i in 5:
anim.update(0.016)
assert_int(visual.plays.size()).is_equal(1)
func test_idle_to_walk_uses_idle_to_gait_blend() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
anim.update(0.016) # settle into Idle
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016)
assert_str(_last_play(visual)["name"]).is_equal("Walk")
assert_float(_last_play(visual)["blend"]) \
.is_equal_approx(SandboxConstants.BLEND["idle_to_gait"], EPS)
func test_walk_to_idle_uses_gait_to_idle_blend() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016)
rig.is_moving = false
rig.current_speed = 0.0
anim.update(0.016)
assert_str(_last_play(visual)["name"]).is_equal("Idle")
assert_float(_last_play(visual)["blend"]) \
.is_equal_approx(SandboxConstants.BLEND["gait_to_idle"], EPS)
func test_stance_toggle_while_idle_keeps_shared_idle_clip() -> void:
# Walk-idle and Sprint-idle share the Idle cell — the clip identity is the edge,
# so no re-play and no gait_changed re-emit on the stance flip.
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
anim.update(0.016)
rig.stance = "Sprint"
anim.update(0.016)
assert_int(visual.plays.size()).is_equal(1)
func test_gait_changed_emitted_once_per_edge() -> void:
# Q-063 seam: one emission per transition, carrying the clip now playing.
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var emitted: Array = []
anim.gait_changed.connect(func(clip: StringName) -> void: emitted.append(String(clip)))
anim.update(0.016) # -> Idle
anim.update(0.016) # no edge
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016) # -> Walk
anim.update(0.016) # no edge
assert_array(emitted).is_equal(["Idle", "Walk"])
# =============================================================================
# 5. Cadence sync (§6.2)
# =============================================================================
func test_speed_scale_matches_speed_over_native_mps() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 1.25 # Walk cardinal leg: 0.5 m / 0.4 s
anim.update(0.016)
assert_float(visual.player.speed_scale) \
.is_equal_approx(1.25 / SandboxConstants.NATIVE_MPS["Walk"], EPS)
func test_speed_scale_clamped_at_catchup_burst() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 10.0 # 3x catch-up burst far beyond native
anim.update(0.016)
assert_float(visual.player.speed_scale) \
.is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.y, EPS)
func test_speed_scale_clamped_at_floor() -> void:
# Hysteresis window: at-target (speed 0) but still "moving" — clamp floor, not 0.
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 0.0
anim.update(0.016)
assert_float(visual.player.speed_scale) \
.is_equal_approx(SandboxConstants.SPEED_SCALE_CLAMP.x, EPS)
func test_idle_runs_at_native_rate() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 2.5
anim.update(0.016)
rig.is_moving = false
rig.current_speed = 0.0
anim.update(0.016)
assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS)
# =============================================================================
# 6. gait<->gait phase preservation (§6.3)
# =============================================================================
func test_gait_to_gait_preserves_phase() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016) # -> Walk
# Mid-stride: half way through the Walk loop.
visual.player.current_animation_position = 0.5 * visual.player.current_animation_length
rig.stance = "Sprint"
rig.current_speed = 2.5
anim.update(0.016) # -> Sprint, gait<->gait
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
assert_int(visual.player.seeks.size()).is_equal(1)
# phase 0.5 into Sprint's 0.67 s loop; update=false keeps the crossfade pose.
assert_float(visual.player.seeks[0][0]) \
.is_equal_approx(0.5 * StubAnimPlayer.LENGTHS["Sprint"], EPS)
assert_bool(visual.player.seeks[0][1]).is_false()
func test_skip_phase_seek_flag_disables_the_seek() -> void:
# §6.3 caveat fallback: if seek-during-blend cancels the crossfade in the live
# scene, the flag drops the seek and the 0.15 s blend masks the resync.
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
anim.skip_phase_seek = true
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016)
visual.player.current_animation_position = 0.5 * visual.player.current_animation_length
rig.stance = "Sprint"
rig.current_speed = 2.5
anim.update(0.016)
assert_str(_last_play(visual)["name"]).is_equal("Sprint")
assert_int(visual.player.seeks.size()).is_equal(0)
func test_idle_transitions_do_not_phase_seek() -> void:
# Phase preservation is gait<->gait only — an idle edge starts the clip normally.
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
anim.update(0.016) # -> Idle
visual.player.current_animation_position = 1.0
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016) # Idle -> Walk
assert_int(visual.player.seeks.size()).is_equal(0)
# =============================================================================
# 7. Teleport hard reset (§6.3)
# =============================================================================
func test_teleport_replays_with_zero_blend_and_rewinds() -> void:
var m := _make_machine()
var anim: LocomotionAnim = m[0]
var rig: StubRig = m[1]
var visual: StubVisual = m[2]
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016) # -> Walk, mid-session
visual.player.current_animation_position = 0.7
rig.is_moving = false # rig snapped at the teleport target
rig.current_speed = 0.0
anim.notify_teleport()
assert_str(_last_play(visual)["name"]).is_equal("Idle")
assert_float(_last_play(visual)["blend"]) \
.is_equal_approx(SandboxConstants.BLEND["teleport"], EPS)
# play() on an already-current clip does not rewind — the reset must seek 0.
assert_int(visual.player.seeks.size()).is_equal(1)
assert_float(visual.player.seeks[0][0]).is_equal_approx(0.0, EPS)
assert_float(visual.player.speed_scale).is_equal_approx(1.0, EPS)
func test_setup_connects_rig_teleported_signal_when_present() -> void:
# The rig contract (§4.0/§7) emits `teleported(pos_m)`; setup() auto-wires the
# reset. The stub signal signature mirrors locomotion_rig.gd:42.
var rig := SignallingStubRig.new()
var visual := StubVisual.new()
var anim := LocomotionAnim.new()
anim.setup(rig, visual)
rig.is_moving = true
rig.current_speed = 1.25
anim.update(0.016)
rig.is_moving = false
rig.current_speed = 0.0
rig.teleported.emit(Vector3(25.25, 0.0, 29.25))
assert_str(_last_play(visual)["name"]).is_equal("Idle")
assert_float(_last_play(visual)["blend"]) \
.is_equal_approx(SandboxConstants.BLEND["teleport"], EPS)
class SignallingStubRig:
extends RefCounted
signal teleported(pos_m: Vector3)
var stance: String = "Walk"
var is_moving: bool = false
var current_speed: float = 0.0
+264
View File
@@ -0,0 +1,264 @@
## Greybox tile store contract (T-1088 design §3, §10.4): accumulation across
## LOS-filtered snapshots, never-evict / last-observation-wins, and the
## four-state visibility flips — on synthetic GameState.visible_tiles dicts,
## plus a fixture replay through the full decode pipeline
## (tests/fixtures/gauntlet/*.msgpack -> Protocol.decode_snapshot() ->
## GameState.apply_snapshot() -> Store), per design-input §1.6.
##
## Headless by design: only the Store inner class (RefCounted) is exercised —
## the GreyboxWorld painter node (MultiMesh writes, shaders) is §10.1/§10.2's
## live/visual job.
class_name TestGreyboxStore
extends GdUnitTestSuite
const Store := GreyboxWorld.Store
## Repo-relative fixture dir (outside res:// — resolved via globalize, the
## visual_capture.gd:177-179 pattern). Regenerate: make fixtures-gauntlet.
const FIXTURE_DIR := "tests/fixtures/gauntlet"
## Live-wire dict shape after the snapshot_handler.gd:52-64 merge:
## {x, y, z, visibility, type} with type already lowercased by protocol.gd.
func _tile(x: int, y: int, type: String, visibility: String = "Forward") -> Dictionary:
return {"x": x, "y": y, "z": 0, "visibility": visibility, "type": type}
## TestHarness "tiles" shape — no visibility key (design-input §1.5).
func _harness_tile(x: int, y: int, type: String) -> Dictionary:
return {"x": x, "y": y, "z": 0, "type": type}
# -- accumulation + never-evict (§3.1) ------------------------------------------
func test_ingest_accumulates_across_snapshots() -> void:
var store := Store.new()
store.ingest([_tile(1, 1, "floor"), _tile(2, 1, "wall")])
store.ingest([_tile(3, 1, "floor"), _tile(4, 1, "floor")])
assert_int(store.size()).is_equal(4)
assert_bool(store.has_tile(Vector3i(1, 1, 0))).is_true()
assert_bool(store.has_tile(Vector3i(4, 1, 0))).is_true()
func test_added_reported_once_then_diff_stays_empty() -> void:
var store := Store.new()
var first: Dictionary = store.ingest([_tile(1, 1, "floor")])
var second: Dictionary = store.ingest([_tile(1, 1, "floor")])
assert_array(first["added"]).contains_exactly([Vector3i(1, 1, 0)])
assert_array(second["added"]).is_empty()
assert_array(second["rekinded"]).is_empty()
assert_array(second["recolored"]).is_empty()
func test_never_evicts() -> void:
# Walked away — LOS goes empty for many snapshots; the tile stays known.
var store := Store.new()
store.ingest([_tile(5, 5, "wall")])
for i in 10:
store.ingest([])
assert_int(store.size()).is_equal(1)
assert_bool(store.has_tile(Vector3i(5, 5, 0))).is_true()
assert_int(store.kind_of(Vector3i(5, 5, 0))).is_equal(Store.Kind.WALL)
func test_kinds_parse_and_doors_collapse_to_floor() -> void:
var store := Store.new()
store.ingest(
[_tile(1, 0, "floor"), _tile(2, 0, "wall"), _tile(3, 0, "door"), _tile(4, 0, "object")]
)
assert_int(store.kind_of(Vector3i(1, 0, 0))).is_equal(Store.Kind.FLOOR)
assert_int(store.kind_of(Vector3i(2, 0, 0))).is_equal(Store.Kind.WALL)
# Wire tile_kind is walkability-derived (query.rs:78-82); doors are entities
# on the wire — Door/Object collapse to FLOOR (§3.1).
assert_int(store.kind_of(Vector3i(3, 0, 0))).is_equal(Store.Kind.FLOOR)
assert_int(store.kind_of(Vector3i(4, 0, 0))).is_equal(Store.Kind.FLOOR)
func test_last_observation_wins_kind_flip() -> void:
var store := Store.new()
store.ingest([_tile(7, 7, "wall")])
var diff: Dictionary = store.ingest([_tile(7, 7, "floor")])
assert_int(store.kind_of(Vector3i(7, 7, 0))).is_equal(Store.Kind.FLOOR)
assert_array(diff["rekinded"]).contains_exactly([Vector3i(7, 7, 0)])
assert_array(diff["added"]).is_empty()
assert_int(store.size()).is_equal(1)
func test_z_level_distinguishes_tiles() -> void:
var store := Store.new()
store.ingest(
[
{"x": 1, "y": 1, "z": 0, "visibility": "Forward", "type": "floor"},
{"x": 1, "y": 1, "z": 1, "visibility": "Forward", "type": "wall"},
]
)
assert_int(store.size()).is_equal(2)
assert_int(store.kind_of(Vector3i(1, 1, 0))).is_equal(Store.Kind.FLOOR)
assert_int(store.kind_of(Vector3i(1, 1, 1))).is_equal(Store.Kind.WALL)
func test_malformed_entries_are_skipped() -> void:
var store := Store.new()
store.ingest([42, {"y": 3}, _tile(1, 1, "floor")])
assert_int(store.size()).is_equal(1)
# -- four-state visibility (§3.2) -------------------------------------------------
func test_visibility_states_parse() -> void:
var store := Store.new()
store.ingest(
[
_tile(1, 0, "floor", "Forward"),
_tile(2, 0, "floor", "Peripheral"),
_tile(3, 0, "wall", "BoundaryWall"),
]
)
assert_int(store.state_of(Vector3i(1, 0, 0))).is_equal(Store.Vis.FORWARD)
assert_int(store.state_of(Vector3i(2, 0, 0))).is_equal(Store.Vis.PERIPHERAL)
assert_int(store.state_of(Vector3i(3, 0, 0))).is_equal(Store.Vis.BOUNDARY_WALL)
func test_harness_tiles_without_visibility_read_forward() -> void:
var store := Store.new()
store.ingest([_harness_tile(1, 1, "floor")])
assert_int(store.state_of(Vector3i(1, 1, 0))).is_equal(Store.Vis.FORWARD)
func test_out_of_sight_flips_to_remembered_exactly_once() -> void:
var store := Store.new()
store.ingest([_tile(1, 1, "floor")])
var leave: Dictionary = store.ingest([])
assert_int(store.state_of(Vector3i(1, 1, 0))).is_equal(Store.Vis.REMEMBERED)
assert_array(leave["recolored"]).contains_exactly([Vector3i(1, 1, 0)])
# Still-remembered tiles must not re-emit the flip on later snapshots —
# diff-only painting depends on it (§3.1, no full-buffer rebuilds).
var later: Dictionary = store.ingest([])
assert_array(later["recolored"]).is_empty()
func test_four_state_flip_sequence() -> void:
# Forward -> Peripheral -> BoundaryWall -> (absent) Remembered -> Forward,
# each flip emitted as exactly one recolor — never a re-add.
var store := Store.new()
var key := Vector3i(9, 9, 0)
store.ingest([_tile(9, 9, "wall", "Forward")])
assert_int(store.state_of(key)).is_equal(Store.Vis.FORWARD)
var to_peripheral: Dictionary = store.ingest([_tile(9, 9, "wall", "Peripheral")])
assert_int(store.state_of(key)).is_equal(Store.Vis.PERIPHERAL)
assert_array(to_peripheral["recolored"]).contains_exactly([key])
var to_boundary: Dictionary = store.ingest([_tile(9, 9, "wall", "BoundaryWall")])
assert_int(store.state_of(key)).is_equal(Store.Vis.BOUNDARY_WALL)
assert_array(to_boundary["recolored"]).contains_exactly([key])
var to_remembered: Dictionary = store.ingest([])
assert_int(store.state_of(key)).is_equal(Store.Vis.REMEMBERED)
assert_array(to_remembered["recolored"]).contains_exactly([key])
var back: Dictionary = store.ingest([_tile(9, 9, "wall", "Forward")])
assert_int(store.state_of(key)).is_equal(Store.Vis.FORWARD)
assert_array(back["recolored"]).contains_exactly([key])
assert_array(back["added"]).is_empty()
assert_int(store.size()).is_equal(1)
func test_never_seen_is_the_void() -> void:
# Never-seen tiles have no state — nothing rendered; perception is upstream,
# server-enforced (§3.2).
var store := Store.new()
store.ingest([_tile(1, 1, "floor")])
assert_bool(store.has_tile(Vector3i(99, 99, 0))).is_false()
assert_int(store.state_of(Vector3i(99, 99, 0))).is_equal(-1)
assert_int(store.kind_of(Vector3i(99, 99, 0))).is_equal(-1)
# -- to_dict() persistence seam (design §0 scope fence) ----------------------------
func test_to_dict_exports_knowledge_copy() -> void:
var store := Store.new()
store.ingest([_tile(1, 1, "floor"), _tile(2, 1, "wall")])
var exported: Dictionary = store.to_dict()
assert_int(exported.size()).is_equal(2)
assert_int(exported[Vector3i(1, 1, 0)]).is_equal(Store.Kind.FLOOR)
assert_int(exported[Vector3i(2, 1, 0)]).is_equal(Store.Kind.WALL)
# A copy — the seam must not expose live store state.
exported.erase(Vector3i(1, 1, 0))
assert_int(store.size()).is_equal(2)
# -- fixture replay (design §10.4; full pipeline per design-input §1.6) -------------
func test_fixture_replay_hub_spawn_then_movement() -> void:
# Fixture bytes -> Protocol.decode_snapshot() (real wire decode incl. the
# tile_kind -> type mapping) -> GameState.apply_snapshot() (wire-spelling
# merge) -> Store — the exact path the live sandbox runs. Expectations are
# computed from the decoded arrays, not hardcoded, so fixture regeneration
# cannot silently skew the assertions.
var spawn: Dictionary = _decode_fixture("hub_spawn")
var moved: Dictionary = _decode_fixture("hub_after_movement")
var spawn_keys := _key_set(spawn["visible_tiles"])
var moved_keys := _key_set(moved["visible_tiles"])
assert_bool(spawn_keys.is_empty()).is_false()
assert_bool(moved_keys.is_empty()).is_false()
var store := Store.new()
GameState.apply_snapshot(spawn)
var first: Dictionary = store.ingest(GameState.visible_tiles)
assert_int(store.size()).is_equal(spawn_keys.size())
assert_int((first["added"] as Array).size()).is_equal(spawn_keys.size())
# The Hub spawn frame discloses both kinds — walls fringe the floor cone.
assert_bool(_contains_kind(store, spawn_keys, Store.Kind.FLOOR)).is_true()
assert_bool(_contains_kind(store, spawn_keys, Store.Kind.WALL)).is_true()
# The Hub spawn tile (50, 58) itself is a disclosed floor (design-input §1.6).
assert_int(store.kind_of(Vector3i(50, 58, 0))).is_equal(Store.Kind.FLOOR)
GameState.apply_snapshot(moved)
store.ingest(GameState.visible_tiles)
# Accumulation: the union of both frames, nothing evicted.
var union := spawn_keys.duplicate()
union.merge(moved_keys)
assert_int(store.size()).is_equal(union.size())
# Every tile of the moved frame is visible now (never REMEMBERED)...
var visible_marked_remembered := 0
for key: Vector3i in moved_keys:
if store.state_of(key) == Store.Vis.REMEMBERED:
visible_marked_remembered += 1
assert_int(visible_marked_remembered).is_equal(0)
# ...and every spawn-only tile has dimmed to REMEMBERED.
var stale_not_remembered := 0
for key: Vector3i in spawn_keys:
if not moved_keys.has(key) and store.state_of(key) != Store.Vis.REMEMBERED:
stale_not_remembered += 1
assert_int(stale_not_remembered).is_equal(0)
func _decode_fixture(fixture_name: String) -> Dictionary:
# Fixtures live outside res:// — resolve the repo root from the project dir
# (pattern: visual_capture.gd:177-179).
var repo_root := ProjectSettings.globalize_path("res://").rstrip("/").get_base_dir()
var path := repo_root.path_join(FIXTURE_DIR).path_join(fixture_name + ".msgpack")
var file := FileAccess.open(path, FileAccess.READ)
assert_that(file).is_not_null().override_failure_message(
"Gauntlet fixture missing: %s — run 'make fixtures-gauntlet'" % path
)
var bytes := file.get_buffer(file.get_length())
file.close()
var snapshot: Variant = Protocol.decode_snapshot(bytes)
assert_bool(snapshot is Dictionary).is_true()
return snapshot if snapshot is Dictionary else {}
func _key_set(tiles: Array) -> Dictionary:
var keys: Dictionary = {}
for tile: Dictionary in tiles:
keys[Vector3i(int(tile["x"]), int(tile["y"]), int(tile["z"]))] = true
return keys
func _contains_kind(store: GreyboxWorld.Store, keys: Dictionary, kind: int) -> bool:
for key: Vector3i in keys:
if store.kind_of(key) == kind:
return true
return false
+237
View File
@@ -0,0 +1,237 @@
## T-1088 (design §9, §10.4): the InputMapper facing_angle_provider seam and the
## sandbox mouse-aim provider's pure angle math.
##
## The provider math is tested through the static
## SandboxMouseAimProvider.compute_facing_angle() with synthetic rays/transforms —
## no viewport or camera needed headless. The seam tests drive the InputMapper
## autoload directly; the legacy 2D canvas-transform path is made deterministic by
## positioning GameState.player_position relative to the CURRENT mouse position
## (no assumption about where the headless mouse sits).
class_name TestLocomotionInput
extends GdUnitTestSuite
const MouseAimProvider := preload("res://scripts/sandbox/mouse_aim_provider.gd")
const EPS := 0.000001
## Deadzone used by the synthetic-math tests (mirrors SandboxConstants.MOUSE_AIM_DEADZONE_M).
const DEADZONE := 0.1
## Ray pointing straight down at the ground plane.
const DOWN := Vector3(0.0, -1.0, 0.0)
func after_test() -> void:
InputMapper.facing_angle_provider = Callable()
InputMapper.reset_facing_state()
GameState.player_position = Vector2.ZERO
# -- compute_facing_angle: cardinal/diagonal directions (identity WorldRoot) --------
func test_math_hit_east_of_rig_is_zero() -> void:
# Straight-down ray 1 m east (+X) of the rig -> sim angle 0 (East).
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_hit_south_of_rig_is_plus_half_pi() -> void:
# Local +Z = sim South (Y-down radians): hit at +Z -> +PI/2.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI / 2.0, EPS)
func test_math_hit_west_of_rig_is_pi() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(-1.0, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI, EPS)
func test_math_hit_north_of_rig_is_minus_half_pi() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 10.0, -1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(-PI / 2.0, EPS)
func test_math_hit_southeast_of_rig_is_quarter_pi() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI / 4.0, EPS)
# -- compute_facing_angle: WorldRoot transform is undone -----------------------------
func test_math_world_rotation_undone() -> void:
# The D-148 45° map rotation must NOT skew the sim angle. A world-space hit at
# the rotated image of local (1,0,0) must still read as East (0.0).
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3.ZERO)
var hit_world := xf * Vector3(1.0, 0.0, 0.0)
var a: float = MouseAimProvider.compute_facing_angle(
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_world_translation_undone() -> void:
# A translated WorldRoot: hit at the world image of local (0,0,1) -> South.
var xf := Transform3D(Basis.IDENTITY, Vector3(10.0, 0.0, -3.0))
var hit_world := xf * Vector3(0.0, 0.0, 1.0)
var a: float = MouseAimProvider.compute_facing_angle(
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, Vector3.ZERO, DEADZONE
)
assert_float(a).is_equal_approx(PI / 2.0, EPS)
func test_math_rig_offset_and_rotation_compose() -> void:
# Rotated WorldRoot + rig away from the origin: hit at the world image of the
# local point 1 m east of the rig -> East, regardless of either offset.
var xf := Transform3D(Basis(Vector3.UP, deg_to_rad(45.0)), Vector3(5.0, 0.0, 7.0))
var rig_local := Vector3(2.0, 0.0, 3.0)
var hit_world := xf * (rig_local + Vector3(1.0, 0.0, 0.0))
var a: float = MouseAimProvider.compute_facing_angle(
hit_world + Vector3(0.0, 10.0, 0.0), DOWN, xf, rig_local, DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_oblique_ray_like_ortho_camera() -> void:
# A -30°-pitch-style oblique ray (not straight down) still lands on y=0
# correctly: origin (0, 5, 8.66), dir (0, -0.5, -0.866) -> hit (0, 0, 0);
# rig 1 m west of the hit -> East.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 5.0, 8.66),
Vector3(0.0, -0.5, -0.866),
Transform3D.IDENTITY,
Vector3(-1.0, 0.0, 0.0),
DEADZONE
)
assert_float(a).is_equal_approx(0.0, EPS)
# -- compute_facing_angle: NAN cases (deadzone + degenerate rays) --------------------
func test_math_inside_deadzone_is_nan() -> void:
# 0.05 m from the rig < 0.1 m deadzone -> NAN (no update; the 2D jitter-guard mirror).
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.05, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_bool(is_nan(a)).is_true()
func test_math_just_outside_deadzone_is_finite() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.2, 10.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_bool(is_finite(a)).is_true()
assert_float(a).is_equal_approx(0.0, EPS)
func test_math_ray_parallel_to_ground_is_nan() -> void:
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, 10.0, 0.0),
Vector3(1.0, 0.0, 0.0),
Transform3D.IDENTITY,
Vector3.ZERO,
DEADZONE
)
assert_bool(is_nan(a)).is_true()
func test_math_ground_plane_behind_ray_is_nan() -> void:
# Origin below the plane, ray pointing further down -> t < 0 -> NAN.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(0.0, -5.0, 0.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_bool(is_nan(a)).is_true()
func test_math_angle_feeds_octant_snap() -> void:
# The provider's output is consumed by InputMapper._angle_to_octant — a
# southeast hit must snap to the "Southeast" wire octant.
var a: float = MouseAimProvider.compute_facing_angle(
Vector3(1.0, 10.0, 1.0), DOWN, Transform3D.IDENTITY, Vector3.ZERO, DEADZONE
)
assert_str(InputMapper._angle_to_octant(a)).is_equal("Southeast")
# -- provider instance guards (no viewport needed) ------------------------------------
func test_provider_with_null_nodes_returns_nan() -> void:
var provider := MouseAimProvider.new(null, null, null)
assert_bool(is_nan(provider.get_facing_angle())).is_true()
func test_provider_with_out_of_tree_nodes_returns_nan() -> void:
var camera: Camera3D = auto_free(Camera3D.new())
var world_root: Node3D = auto_free(Node3D.new())
var rig: Node3D = auto_free(Node3D.new())
var provider := MouseAimProvider.new(camera, world_root, rig)
assert_bool(is_nan(provider.get_facing_angle())).is_true()
# -- InputMapper seam ------------------------------------------------------------------
func test_seam_finite_provider_updates_facing_and_octant() -> void:
InputMapper.facing_angle_provider = func() -> float: return PI / 4.0
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, EPS)
assert_str(InputMapper.facing_octant).is_equal("Southeast")
func test_seam_nan_provider_leaves_facing_and_blocks_2d_path() -> void:
# Arrange the 2D path so it WOULD rewrite facing if it ran (player 100 px
# away from the mouse on screen), then install a NAN provider: the early
# return must both skip the update and block the 2D path entirely.
_place_player_at_screen_delta(Vector2(100.0, 100.0))
InputMapper.facing_angle = 0.42
InputMapper.facing_octant = "East"
InputMapper.facing_angle_provider = func() -> float: return NAN
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
assert_str(InputMapper.facing_octant).is_equal("East")
func test_seam_unset_provider_falls_through_to_2d_path() -> void:
# Provider unset (default Callable()): the new branch must not fire and the
# legacy 2D canvas-transform path must run unchanged — with the player placed
# 100 px up-left of the mouse, it computes atan2(100, 100) = PI/4 (Southeast).
_place_player_at_screen_delta(Vector2(100.0, 100.0))
InputMapper.facing_angle_provider = Callable()
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(PI / 4.0, 0.001)
assert_str(InputMapper.facing_octant).is_equal("Southeast")
func test_seam_unset_provider_leaves_facing_untouched_inside_2d_jitter_guard() -> void:
# Provider unset + player exactly under the mouse: neither the new branch nor
# the 2D path (its own <= 2 px jitter guard) may touch facing.
_place_player_at_screen_delta(Vector2.ZERO)
InputMapper.facing_angle = 0.42
InputMapper.facing_octant = "East"
InputMapper.facing_angle_provider = Callable()
InputMapper._update_facing_from_mouse()
assert_float(InputMapper.facing_angle).is_equal_approx(0.42, EPS)
assert_str(InputMapper.facing_octant).is_equal("East")
# Position GameState.player_position so that (mouse_screen - player_screen) equals
# delta_px EXACTLY, inverting the 2D path's own math (player_position * TILE_SIZE
# through the canvas transform). This pins the legacy path's outcome without any
# assumption about the headless mouse position or canvas transform.
func _place_player_at_screen_delta(delta_px: Vector2) -> void:
var vp := InputMapper.get_viewport()
var canvas_xf := vp.get_canvas_transform()
var C := load("res://scripts/constants.gd")
var player_screen := vp.get_mouse_position() - delta_px
var player_world_px := canvas_xf.affine_inverse() * player_screen
GameState.player_position = player_world_px / float(C.TILE_SIZE)
+378
View File
@@ -0,0 +1,378 @@
## LocomotionRig math (T-1088 design §4, §5, §10.4): per-leg constant-velocity
## derivation (cardinal 1.25 m/s / diagonal 1.77 m/s at Walk 400 ms), the 3x
## catch-up clamp, the 2.5 m snap threshold, idle hysteresis timing, paused-tick
## idempotence, blocked-move zero-motion (Q-020 bump-to-turn), and shortest-arc
## yaw easing under the per-stance TURN_BUDGET_DEG clamps.
##
## Pure static-core functions are tested directly; wrapper tests instantiate the
## rig out-of-tree (never added as a child, so the engine never drives _process —
## frames are stepped manually for deterministic timing).
class_name TestLocomotionMath
extends GdUnitTestSuite
const EPS := 0.000001
## 60 fps frame used by the yaw tests.
const DT := 1.0 / 60.0
func _make_rig() -> LocomotionRig:
var rig: LocomotionRig = auto_free(LocomotionRig.new())
rig.model_root = auto_free(Node3D.new())
return rig
# -- leg-speed derivation (§4.1: dist / interval, clamped) --------------------------
func test_leg_speed_cardinal_walk() -> void:
# One cardinal subtile at Walk: 0.5 m / 0.4 s = 1.25 m/s.
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.4)).is_equal_approx(1.25, EPS)
func test_leg_speed_diagonal_walk() -> void:
# Diagonal step costs the same interval (no sqrt(2) on the wire, D-053):
# 0.5 * sqrt(2) / 0.4 = ~1.77 m/s — arrives exactly when the next step lands.
var diag := 0.5 * sqrt(2.0)
assert_float(LocomotionRig.derive_leg_speed(diag, 0.4)).is_equal_approx(1.767767, 0.0001)
func test_leg_speed_sprint_cardinal() -> void:
# Sprint window 200 ms: 0.5 / 0.2 = 2.5 m/s.
assert_float(LocomotionRig.derive_leg_speed(0.5, 0.2)).is_equal_approx(2.5, EPS)
func test_leg_speed_catchup_clamped_at_3x() -> void:
# 4-tile latest-wins delta at Walk: raw 2.0/0.4 = 5.0 m/s, clamped to
# CATCHUP_MAX_FACTOR (3.0) * base = 3.75 m/s — feet speed up, never blur.
assert_float(LocomotionRig.derive_leg_speed(2.0, 0.4)).is_equal_approx(3.75, EPS)
func test_leg_speed_floored_at_base() -> void:
# Sub-subtile residue (late arrival) still closes at least at base speed —
# the tail of a leg never crawls.
assert_float(LocomotionRig.derive_leg_speed(0.1, 0.4)).is_equal_approx(1.25, EPS)
# -- target classification (§4.1: first snap / ignore / teleport / step) -------------
func test_classify_first_snapshot_snaps() -> void:
var got := LocomotionRig.classify_target(
false, Vector3.ZERO, Vector3.ZERO, Vector3(25.25, 0.0, 29.25)
)
assert_int(got).is_equal(LocomotionRig.TargetAction.SNAP_FIRST)
func test_classify_same_target_ignored() -> void:
# Paused ticks keep delivering identical positions (§4.3) — idempotent.
var target := Vector3(1.25, 0.0, 2.25)
var got := LocomotionRig.classify_target(true, target, target, target)
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
func test_classify_same_target_ignored_mid_leg() -> void:
# A repeat while still chasing keeps the current leg speed (no re-derivation
# from the shrinking remaining distance).
var target := Vector3(1.75, 0.0, 2.25)
var render := Vector3(1.5, 0.0, 2.25)
var got := LocomotionRig.classify_target(true, render, target, target)
assert_int(got).is_equal(LocomotionRig.TargetAction.IGNORE)
func test_classify_step_within_snap_dist() -> void:
var render := Vector3(1.25, 0.0, 2.25)
var got := LocomotionRig.classify_target(
true, render, render, render + Vector3(0.5, 0.0, 0.0)
)
assert_int(got).is_equal(LocomotionRig.TargetAction.STEP)
func test_classify_snap_threshold_boundary() -> void:
# SNAP_DIST_M is strict: exactly 2.5 m (5 subtiles) still glides — matches
# the 2D TELEPORT_DISTANCE_THRESHOLD semantics; beyond it teleports.
var render := Vector3.ZERO
var at_limit := LocomotionRig.classify_target(
true, render, render, Vector3(2.5, 0.0, 0.0)
)
assert_int(at_limit).is_equal(LocomotionRig.TargetAction.STEP)
var beyond := LocomotionRig.classify_target(
true, render, render, Vector3(2.51, 0.0, 0.0)
)
assert_int(beyond).is_equal(LocomotionRig.TargetAction.TELEPORT)
func test_classify_teleport_measured_from_render_pos() -> void:
# Teleport distance is render-pos -> new target (§4.1), not target -> target.
var render := Vector3.ZERO
var old_target := Vector3(2.0, 0.0, 0.0)
var got := LocomotionRig.classify_target(
true, render, old_target, Vector3(2.0, 0.0, 2.0) # 2.83 m from render
)
assert_int(got).is_equal(LocomotionRig.TargetAction.TELEPORT)
# -- idle hysteresis (§4.2: IDLE_ENTER_DELAY_S = 0.18) --------------------------------
func test_moving_while_not_at_target() -> void:
assert_bool(LocomotionRig.is_moving_state(false, 99.0)).is_true()
func test_hysteresis_holds_moving_within_delay() -> void:
# Arrived, but only 0.1 s at target — still "moving" (covers 2-3 ticks of
# snapshot jitter at every stance).
assert_bool(LocomotionRig.is_moving_state(true, 0.1)).is_true()
func test_hysteresis_enters_idle_at_delay() -> void:
assert_bool(LocomotionRig.is_moving_state(true, 0.18)).is_false()
assert_bool(LocomotionRig.is_moving_state(true, 0.5)).is_false()
func test_idle_timer_accumulates_at_target() -> void:
assert_float(LocomotionRig.advance_idle_timer(0.1, true, 0.05)).is_equal_approx(0.15, EPS)
func test_idle_timer_resets_when_leg_starts() -> void:
assert_float(LocomotionRig.advance_idle_timer(0.5, false, 0.05)).is_equal_approx(0.0, EPS)
# -- yaw target selection (§5 authority table) -----------------------------------------
func test_facing_moving_uses_wire_octant() -> void:
# Moving: server Facing IS the motion direction — mouse aim is visually ignored.
var got := LocomotionRig.select_yaw_target(true, "East", "North", false, 0.0)
assert_float(got).is_equal_approx(PI / 2.0, EPS)
func test_facing_moving_ignores_suppression() -> void:
var got := LocomotionRig.select_yaw_target(true, "East", "North", true, 0.0)
assert_float(got).is_equal_approx(PI / 2.0, EPS)
func test_facing_idle_uses_provider_octant() -> void:
var got := LocomotionRig.select_yaw_target(false, "East", "North", false, 0.0)
assert_float(got).is_equal_approx(PI, EPS)
func test_facing_idle_without_provider_falls_back_to_wire() -> void:
# NPC path: no provider installed -> pure wire facing, single code path.
var got := LocomotionRig.select_yaw_target(false, "East", "", false, 0.0)
assert_float(got).is_equal_approx(PI / 2.0, EPS)
func test_facing_frozen_under_suppression_when_idle() -> void:
# Dialogue / free camera: InputMapper stops sending octants — hold the last
# target instead of showing an octant the server was never told.
var held := 0.123
var got := LocomotionRig.select_yaw_target(false, "East", "North", true, held)
assert_float(got).is_equal_approx(held, EPS)
# -- yaw stepping (§5: shortest-arc lerp_angle under TURN_BUDGET_DEG) --------------------
func test_shortest_arc_wraps_through_pi() -> void:
# -170 deg -> +170 deg is -20 deg through the seam, never +340 deg.
var arc := LocomotionRig.shortest_arc(deg_to_rad(-170.0), deg_to_rad(170.0))
assert_float(arc).is_equal_approx(deg_to_rad(-20.0), EPS)
func test_yaw_step_unclamped_matches_ease() -> void:
# Small 5 deg correction at Walk: eased step (~1 deg at 60 fps) is far under
# the 12 deg/frame budget — pure lerp_angle ease, no clamp.
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
var expected := weight * deg_to_rad(5.0)
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(5.0), "Walk", DT)
assert_float(got).is_equal_approx(expected, EPS)
func test_yaw_budget_clamps_walk_reversal() -> void:
# Near-180 reversal at Walk: eased step (~37 deg) hits the 720 deg/s budget
# -> exactly 12 deg this frame (reversal completes in ~0.25 s, half a step).
var got := LocomotionRig.step_yaw(0.0, deg_to_rad(179.0), "Walk", DT)
assert_float(got).is_equal_approx(deg_to_rad(720.0) * DT, EPS)
func test_yaw_budget_per_stance() -> void:
# Same reversal, different stances: Sprint 1080 -> 18 deg/frame,
# Crouch 420 -> 7 deg/frame.
var target := deg_to_rad(179.0)
var sprint := LocomotionRig.step_yaw(0.0, target, "Sprint", DT)
assert_float(sprint).is_equal_approx(deg_to_rad(1080.0) * DT, EPS)
var crouch := LocomotionRig.step_yaw(0.0, target, "Crouch", DT)
assert_float(crouch).is_equal_approx(deg_to_rad(420.0) * DT, EPS)
func test_yaw_budget_idle_and_unknown_key() -> void:
# Idle budget 600 -> 10 deg/frame; unknown stance keys fall back to Idle.
var target := deg_to_rad(179.0)
var idle := LocomotionRig.step_yaw(0.0, target, "Idle", DT)
assert_float(idle).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
var unknown := LocomotionRig.step_yaw(0.0, target, "Prone", DT)
assert_float(unknown).is_equal_approx(deg_to_rad(600.0) * DT, EPS)
func test_yaw_step_takes_shortest_arc() -> void:
# From -170 deg toward +170 deg: the step is negative (through the seam).
var start := deg_to_rad(-170.0)
var weight := 1.0 - exp(-SandboxConstants.TURN_SHARPNESS * DT)
var expected := start + weight * deg_to_rad(-20.0)
var got := LocomotionRig.step_yaw(start, deg_to_rad(170.0), "Walk", DT)
assert_float(got).is_equal_approx(expected, EPS)
func test_yaw_step_wraps_result_across_seam() -> void:
# Budget-clamped turn crossing -PI: -175 deg - 12 deg wraps to +173 deg.
var got := LocomotionRig.step_yaw(deg_to_rad(-175.0), deg_to_rad(90.0), "Walk", DT)
assert_float(got).is_equal_approx(deg_to_rad(173.0), EPS)
# -- wrapper behavior (out-of-tree rig, manually stepped frames) --------------------------
func test_first_wire_target_snaps_without_teleport_signal() -> void:
var rig := _make_rig()
var emissions: Array = []
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
rig.set_wire_target(Vector3(25.25, 0.0, 29.25), "East", "Walk", 100)
assert_float(rig.position.x).is_equal_approx(25.25, EPS)
assert_float(rig.position.z).is_equal_approx(29.25, EPS)
assert_bool(rig.is_moving).is_false()
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
# Yaw snapped straight to the wire octant (East = +90 deg), no ease-in.
assert_float(rig.model_root.rotation.y).is_equal_approx(PI / 2.0, EPS)
assert_int(emissions.size()).is_equal(0)
func test_paused_repeat_of_same_target_is_idempotent() -> void:
# Paused server keeps sending identical-position frames (§4.3): no motion
# re-trigger, no drift, rig settles idle through normal hysteresis.
var rig := _make_rig()
var target := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(target, "North", "Walk", 10)
for i in 5:
rig.set_wire_target(target, "North", "Walk", 10)
rig._process(0.05)
assert_bool(rig.is_moving).is_false()
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
assert_float(rig.velocity.length()).is_equal_approx(0.0, EPS)
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
func test_blocked_move_turns_in_place_without_motion() -> void:
# Q-020 bump-to-turn (§4.3): the server updates Facing on a blocked move but
# not position — the rig turns to face the wall and stands, zero motion.
var rig := _make_rig()
var target := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(target, "North", "Walk", 1) # snap: yaw = PI (North)
rig.set_wire_target(target, "West", "Walk", 2) # blocked: same tile, new facing
rig._process(DT)
assert_float(rig.position.distance_to(target)).is_equal_approx(0.0, EPS)
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
assert_bool(rig.is_moving).is_false()
# Idle without a provider -> wire facing (West = -90 deg).
assert_float(rig.yaw_target).is_equal_approx(-PI / 2.0, EPS)
# One Idle-budget frame (600 deg/s -> 10 deg) from North toward West,
# shortest arc through the +PI seam: PI + 10 deg wraps to -PI + 10 deg.
assert_float(rig.model_root.rotation.y).is_equal_approx(-PI + deg_to_rad(10.0), EPS)
func test_step_moves_at_constant_leg_speed() -> void:
# Equal displacement per frame — the M2 milestone's numeric core.
var rig := _make_rig()
var start := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(start, "East", "Walk", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
rig._process(0.1)
assert_bool(rig.is_moving).is_true()
assert_float(rig.current_speed).is_equal_approx(1.25, 0.001)
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
assert_float(rig.position.x).is_equal_approx(1.375, 0.001)
rig._process(0.1)
assert_float(rig.position.x).is_equal_approx(1.5, 0.001)
func test_diagonal_step_velocity_components() -> void:
# Diagonal leg at Walk: 1.77 m/s along the diagonal = 1.25 m/s per axis —
# both axes arrive exactly when a cardinal step would.
var rig := _make_rig()
var start := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(start, "Southeast", "Walk", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.5), "Southeast", "Walk", 2)
rig._process(0.1)
assert_float(rig.current_speed).is_equal_approx(1.767767, 0.0001)
assert_float(rig.velocity.x).is_equal_approx(1.25, 0.001)
assert_float(rig.velocity.z).is_equal_approx(1.25, 0.001)
func test_arrival_holds_moving_through_hysteresis_window() -> void:
# Arrive after 0.4 s, then stay "moving" until 0.18 s at target (§4.2).
var rig := _make_rig()
var start := Vector3(1.25, 0.0, 2.25)
rig.set_wire_target(start, "East", "Walk", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Walk", 2)
for i in 4: # 4 x 0.1 s = exactly one Walk window -> arrival
rig._process(0.1)
assert_float(rig.position.x).is_equal_approx(1.75, 0.001)
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
assert_bool(rig.is_moving).is_true() # at target 0.1 s < 0.18 s — held
rig._process(0.1) # at target 0.2 s >= 0.18 s — idle
assert_bool(rig.is_moving).is_false()
func test_teleport_snaps_all_channels_and_emits() -> void:
var rig := _make_rig()
var emissions: Array = []
rig.teleported.connect(func(p: Vector3) -> void: emissions.append(p))
rig.set_wire_target(Vector3(1.25, 0.0, 1.25), "North", "Walk", 1)
var far := Vector3(25.25, 0.0, 29.25) # Home-key Hub return — cross-map jump
rig.set_wire_target(far, "South", "Walk", 2)
assert_int(emissions.size()).is_equal(1)
assert_float((emissions[0] as Vector3).x).is_equal_approx(far.x, EPS)
assert_float(rig.position.distance_to(far)).is_equal_approx(0.0, EPS)
assert_bool(rig.is_moving).is_false()
assert_float(rig.current_speed).is_equal_approx(0.0, EPS)
# Yaw hard-snapped to the post-teleport wire octant (South = 0), no glide.
assert_float(rig.model_root.rotation.y).is_equal_approx(0.0, EPS)
func test_step_window_provider_overrides_default() -> void:
# The player adapter injects InputMapper.MOVE_INTERVAL_MS — the rig itself
# never reads the autoload (§4.0).
var rig := _make_rig()
rig.step_window_ms_provider = func(for_stance: String) -> float:
return 200.0 if for_stance == "Sprint" else 400.0
var start := Vector3(1.25, 0.0, 1.25)
rig.set_wire_target(start, "East", "Sprint", 1)
rig.set_wire_target(start + Vector3(0.5, 0.0, 0.0), "East", "Sprint", 2)
rig._process(0.1)
assert_float(rig.current_speed).is_equal_approx(2.5, 0.001)
func test_idle_facing_provider_steers_idle_yaw() -> void:
# Idle aim: the provider's octant (client-local, already sent as SetFacing)
# becomes the yaw target — mouse-responsive idle facing without wire lag.
var rig := _make_rig()
rig.idle_facing_provider = func() -> String: return "East"
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
rig._process(DT)
assert_bool(rig.is_moving).is_false()
assert_float(rig.yaw_target).is_equal_approx(PI / 2.0, EPS)
func test_suppression_freezes_idle_yaw_target() -> void:
# Dialogue / free camera (§5 row 3): the yaw target holds even though the
# idle provider says otherwise.
var rig := _make_rig()
rig.idle_facing_provider = func() -> String: return "East"
rig.suppression_provider = func() -> bool: return true
rig.set_wire_target(Vector3(1.25, 0.0, 2.25), "North", "Walk", 1)
rig._process(DT)
assert_float(rig.yaw_target).is_equal_approx(PI, EPS) # frozen at North
# Node3D.rotation is float32: written +PI reads back a hair above double PI,
# so wrap_yaw lands on the equivalent -PI. Assert angular identity, not sign.
var arc := LocomotionRig.shortest_arc(PI, rig.model_root.rotation.y)
assert_float(arc).is_equal_approx(0.0, EPS)
+148
View File
@@ -0,0 +1,148 @@
## SandboxSpace golden conversions (T-1088 design §2, §10.4): tile -> world metres,
## wire-float floori recovery (live tile-centers vs TestHarness integers), and the
## verified octant -> yaw table (yaw = PI/2 - theta; South 0, East +90, West -90,
## North 180 degrees).
##
## The East/West cells are the REGRESSION GUARD against character_visual.gd's
## mirrored 2D table (east=-90/west=+90, character_visual.gd:184-193) — copying that
## table makes the model face west while walking east. Never relax these two tests.
class_name TestSandboxSpace
extends GdUnitTestSuite
const EPS := 0.000001
# -- tile_to_world ---------------------------------------------------------------
func test_tile_to_world_origin_center() -> void:
# Tile (0,0) center: subtile 0.5 m, center at +0.25 m on each axis.
var w := SandboxSpace.tile_to_world(Vector3i(0, 0, 0))
assert_float(w.x).is_equal_approx(0.25, EPS)
assert_float(w.y).is_equal_approx(0.0, EPS)
assert_float(w.z).is_equal_approx(0.25, EPS)
func test_tile_to_world_hub_spawn() -> void:
# Gauntlet Hub spawn (50, 58): x = (50 + 0.5) * 0.5 = 25.25, z = (58 + 0.5) * 0.5 = 29.25.
var w := SandboxSpace.tile_to_world(Vector3i(50, 58, 0))
assert_float(w.x).is_equal_approx(25.25, EPS)
assert_float(w.y).is_equal_approx(0.0, EPS)
assert_float(w.z).is_equal_approx(29.25, EPS)
func test_tile_to_world_sim_south_is_local_plus_z() -> void:
# Sim +y (South, Y-down) maps to local +Z: one southward tile step moves +0.5 m in Z only.
var a := SandboxSpace.tile_to_world(Vector3i(10, 20, 0))
var b := SandboxSpace.tile_to_world(Vector3i(10, 21, 0))
assert_float(b.x - a.x).is_equal_approx(0.0, EPS)
assert_float(b.z - a.z).is_equal_approx(0.5, EPS)
func test_tile_to_world_sim_east_is_local_plus_x() -> void:
# Sim +x (East) maps to local +X: one eastward tile step moves +0.5 m in X only.
var a := SandboxSpace.tile_to_world(Vector3i(10, 20, 0))
var b := SandboxSpace.tile_to_world(Vector3i(11, 20, 0))
assert_float(b.x - a.x).is_equal_approx(0.5, EPS)
assert_float(b.z - a.z).is_equal_approx(0.0, EPS)
# -- wire-float recovery (floori, never round) -------------------------------------
func test_wire_to_tile_live_tile_center_floats() -> void:
# Live wire sends tile-center floats (N + 0.5) — floori recovers N.
# round(50.5) would land on 51: the half-a-tile-off failure mode.
assert_object(SandboxSpace.wire_to_tile(50.5, 58.5)).is_equal(Vector3i(50, 58, 0))
func test_wire_to_tile_harness_integer_floats() -> void:
# TestHarness sends integer floats — floori is a no-op, byte-identical both modes.
assert_object(SandboxSpace.wire_to_tile(50.0, 58.0)).is_equal(Vector3i(50, 58, 0))
func test_wire_to_world_recenters() -> void:
# Wire float -> tile index -> recomputed center: 50.5 -> tile 50 -> 25.25 m.
var w := SandboxSpace.wire_to_world(50.5, 58.5)
assert_float(w.x).is_equal_approx(25.25, EPS)
assert_float(w.z).is_equal_approx(29.25, EPS)
func test_wire_to_world_identical_across_modes() -> void:
# The same tile must resolve to the same world point from either wire spelling.
var live := SandboxSpace.wire_to_world(50.5, 58.5)
var harness := SandboxSpace.wire_to_world(50.0, 58.0)
assert_float(live.x).is_equal_approx(harness.x, EPS)
assert_float(live.z).is_equal_approx(harness.z, EPS)
func test_world_to_tile_round_trip() -> void:
# Gauntlet corners + spawn: world_to_tile inverts tile_to_world exactly.
for tile: Vector3i in [
Vector3i(0, 0, 0), Vector3i(50, 58, 0), Vector3i(116, 124, 0), Vector3i(3, 7, 0)
]:
assert_object(SandboxSpace.world_to_tile(SandboxSpace.tile_to_world(tile))).is_equal(tile)
# -- octant -> yaw (all 8; E/W are the mirrored-table regression guard) --------------
func test_octant_yaw_south_is_zero() -> void:
assert_float(SandboxSpace.octant_to_yaw("South")).is_equal_approx(0.0, EPS)
func test_octant_yaw_southeast_is_plus_45() -> void:
assert_float(SandboxSpace.octant_to_yaw("Southeast")).is_equal_approx(PI / 4.0, EPS)
func test_octant_yaw_east_is_plus_90() -> void:
# REGRESSION GUARD: East = +PI/2 (model turns to +X). character_visual.gd's
# mirrored table says east = -90 deg — copying it faces the model west while
# walking east (the fatal flaw of both rejected design candidates).
assert_float(SandboxSpace.octant_to_yaw("East")).is_equal_approx(PI / 2.0, EPS)
func test_octant_yaw_northeast_is_plus_135() -> void:
assert_float(SandboxSpace.octant_to_yaw("Northeast")).is_equal_approx(3.0 * PI / 4.0, EPS)
func test_octant_yaw_north_is_180() -> void:
assert_float(SandboxSpace.octant_to_yaw("North")).is_equal_approx(PI, EPS)
func test_octant_yaw_northwest_is_minus_135() -> void:
assert_float(SandboxSpace.octant_to_yaw("Northwest")).is_equal_approx(-3.0 * PI / 4.0, EPS)
func test_octant_yaw_west_is_minus_90() -> void:
# REGRESSION GUARD: West = -PI/2 (model turns to -X) — the mirror of the
# character_visual.gd 2D table (west = +90 deg there). See East guard above.
assert_float(SandboxSpace.octant_to_yaw("West")).is_equal_approx(-PI / 2.0, EPS)
func test_octant_yaw_southwest_is_minus_45() -> void:
assert_float(SandboxSpace.octant_to_yaw("Southwest")).is_equal_approx(-PI / 4.0, EPS)
# -- derivation internals ------------------------------------------------------------
func test_sim_angle_convention_matches_input_mapper() -> void:
# theta: 0 = East, +PI/2 = South, -PI/2 = North (Y-down radians — server
# vision_cone.rs and InputMapper.facing_angle share this convention).
assert_float(SandboxSpace.octant_to_sim_angle("East")).is_equal_approx(0.0, EPS)
assert_float(SandboxSpace.octant_to_sim_angle("South")).is_equal_approx(PI / 2.0, EPS)
assert_float(SandboxSpace.octant_to_sim_angle("North")).is_equal_approx(-PI / 2.0, EPS)
func test_sim_angle_to_yaw_wraps_into_signed_pi_range() -> void:
# Northwest: theta = -3PI/4 -> PI/2 - theta = 5PI/4 -> wrapped to -3PI/4.
var nw := SandboxSpace.sim_angle_to_yaw(-3.0 * PI / 4.0)
assert_float(nw).is_equal_approx(-3.0 * PI / 4.0, EPS)
# North stays +PI (range (-PI, PI]), matching the golden table's 180 deg.
assert_float(SandboxSpace.sim_angle_to_yaw(-PI / 2.0)).is_equal_approx(PI, EPS)
func test_unknown_octant_defaults_to_north() -> void:
# Defensive default mirrors GameState.player_facing's "North" default.
assert_float(SandboxSpace.octant_to_yaw("Sideways")).is_equal_approx(PI, EPS)
+31 -5
View File
@@ -1,5 +1,6 @@
## Visual test capture engine — boots main.tscn, runs a named scenario or flow,
## captures viewport PNGs for golden comparison or ad-hoc inspection.
## Visual test capture engine — boots the entry's "scene" (default main.tscn),
## runs a named scenario or flow, captures viewport PNGs for golden comparison
## or ad-hoc inspection.
##
## Usage:
## godot --rendering-driver opengl3 --fixed-fps 60 --resolution 960x540 \
@@ -66,10 +67,22 @@ func _run(): # gdlint:disable=max-returns
# Ensure output directory exists
DirAccess.make_dir_recursive_absolute(_output_dir)
# Load main scene (autoloads already initialized from project.godot)
var main_scene = load("res://scenes/main.tscn")
# Per-entry overrides (T-1088, design §10.2 — both additive; existing entries
# without these keys behave byte-identically):
# "scene": alternate root scene (default res://scenes/main.tscn)
# "env": env vars set before the scene boots (e.g. SR_AUTOPILOT) — OS-level,
# so the scene's _ready() reads them exactly like shell-exported vars;
# run-visual has no per-scenario env mechanism, this is it.
var entry_cfg := _entry_config()
var env_vars: Dictionary = entry_cfg.get("env", {})
for env_key: String in env_vars:
OS.set_environment(env_key, str(env_vars[env_key]))
# Load root scene (autoloads already initialized from project.godot)
var scene_path: String = entry_cfg.get("scene", "res://scenes/main.tscn")
var main_scene = load(scene_path)
if main_scene == null:
push_error("visual_capture: failed to load res://scenes/main.tscn")
push_error("visual_capture: failed to load %s" % scene_path)
quit(1)
return
@@ -298,6 +311,19 @@ func _capture(path: String) -> void:
print("visual_capture: captured -> %s (%dx%d)" % [path, image.get_width(), image.get_height()])
## Config entry for the active scenario or flow ({} when the name is unknown —
## the mode runners report that error themselves).
func _entry_config() -> Dictionary:
var section: Dictionary = {}
if not _scenario.is_empty():
section = _config.get("scenarios", {})
return section.get(_scenario, {})
if not _flow.is_empty():
section = _config.get("flows", {})
return section.get(_flow, {})
return {}
func _load_config() -> Dictionary:
# Config is at tests/visual.json relative to repo root.
# Repo root = parent of Godot project root (client/).