feat(engine): live server visual tests and gauntlet snapshot replay

Add live server lifecycle to tests/run-visual (start/stop server per
scenario, parse LISTENING:{port}). Add MessagePack snapshot replay to
visual_capture.gd via Protocol.decode_snapshot() — exercises the full
client pipeline from wire bytes to rendered fog. Three replay scenarios
(hub_spawn, fog_theater, hub_after_movement) plus one live scenario
(fog_live_hub). Add gen_gauntlet_fixtures.rs to produce .msgpack fixtures
from the Gauntlet test world. Add max_diff_pct threshold to visual-diff.
Makefile: add fixtures-gauntlet target, fix build-client double-import,
preserve .godot cache in clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 08:41:09 +01:00
co-authored by Claude Opus 4.6
parent 2189b00c6f
commit ac763fef97
16 changed files with 9034 additions and 9 deletions
+78
View File
@@ -24,6 +24,7 @@ var _flow: String = ""
var _interval: float = 3.0
var _list_mode: bool = false
var _config: Dictionary = {}
var _is_live: bool = false
func _init():
@@ -73,6 +74,19 @@ func _run():
return
var main_node = main_scene.instantiate()
# Live mode: configure server port BEFORE main.gd._ready() calls connect_to_sim()
_is_live = OS.get_environment("SR_LIVE") == "1"
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var port_env := OS.get_environment("SR_PORT")
if port_env.is_empty():
push_error("visual_capture: SR_LIVE=1 but SR_PORT not set")
quit(1)
return
sim_bridge.server_port = int(port_env)
print("visual_capture: live mode — server port %d" % sim_bridge.server_port)
root.add_child(main_node)
# Wait for NoiseTexture2D async generation
@@ -92,6 +106,36 @@ func _run():
for i in range(settle_count):
await process_frame
# Live mode: wait for server connection and first snapshot
if _is_live:
var sim_bridge := root.get_node("/root/SimBridge")
var game_state := root.get_node("/root/GameState")
print("visual_capture: waiting for server connection...")
var max_frames := 300 # 5 seconds at 60fps
var waited := 0
while sim_bridge.state != sim_bridge.ConnectionState.CONNECTED:
if sim_bridge.state == sim_bridge.ConnectionState.ERROR:
push_error("visual_capture: server connection failed")
quit(1)
return
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: connection timeout after %d frames" % waited)
quit(1)
return
print("visual_capture: connected after %d frames" % waited)
# Wait for first snapshot from server
waited = 0
while game_state.current_tick == 0:
await process_frame
waited += 1
if waited >= max_frames:
push_error("visual_capture: no snapshot after %d frames" % waited)
quit(1)
return
print("visual_capture: first snapshot tick=%d (%d frames)" % [game_state.current_tick, waited])
if not _scenario.is_empty():
await _run_scenario(main_node)
elif not _flow.is_empty():
@@ -123,6 +167,40 @@ func _run_scenario(_main_node: Node) -> void:
# Post-tick setup (e.g. zone tint patching)
_scenarios.post_setup(_scenario, root)
# Replay snapshot: inject a real server snapshot through the FULL client pipeline.
# Loads MessagePack bytes (exact wire format from server), decodes via Protocol.gd,
# then applies through GameState → FogState → shader — same path as live game.
var replay_path: String = scenario_cfg.get("replay_snapshot", "")
if not replay_path.is_empty():
var project_root := ProjectSettings.globalize_path("res://")
var repo_root := project_root.rstrip("/").get_base_dir()
var abs_path := repo_root.path_join(replay_path)
var rf := FileAccess.open(abs_path, FileAccess.READ)
if rf == null:
push_error("visual_capture: cannot open replay snapshot %s" % abs_path)
quit(1)
return
var replay_bytes := rf.get_buffer(rf.get_length())
rf.close()
# Decode through Protocol.decode_snapshot() — same as live IPC receive path.
# This exercises: msgpack decode → entity decode → tile_kind→type mapping → etc.
var replay_data: Variant = Protocol.decode_snapshot(replay_bytes)
if replay_data == null or not replay_data is Dictionary:
push_error("visual_capture: Protocol.decode_snapshot failed for %s" % abs_path)
quit(1)
return
print("visual_capture: replaying %s (%d bytes, tick=%s, %d tiles)" % [
replay_path, replay_bytes.size(),
str(replay_data.get("tick", "?")),
replay_data.get("visible_tiles", []).size()])
var game_state := root.get_node("/root/GameState")
var fog_state := root.get_node("/root/FogState")
game_state.apply_snapshot(replay_data)
fog_state.update_from_state()
# Extra frames for fog uniform propagation
for i in range(4):
await process_frame
# Extra frames for state propagation + viewport texture lag
await process_frame
await process_frame
+1
View File
@@ -0,0 +1 @@
uid://pd1qpgxiodig
+11
View File
@@ -83,6 +83,17 @@ func apply_setup(scenario_name: String, tree_root: Node) -> bool:
sim_bridge.harness.player_pos = Vector2i(11, 9)
sim_bridge.harness.process_input("Interact")
"fog_live_replay", "fog_theater_replay", "fog_boundary_replay":
# Replay real server snapshots via MessagePack → Protocol.decode_snapshot().
# Setup handled by visual_capture.gd (reads replay_snapshot from config).
pass
"fog_live_hub":
# Live server connection — Hub spawn position.
# No setup needed: server starts in --test-mode with Gauntlet,
# player spawns at Hub (50,58). Captures real fog pipeline output.
pass
_:
push_warning("VisualScenarios: unknown scenario '%s'" % scenario_name)
return false
+1
View File
@@ -0,0 +1 @@
uid://dna10a0ln5pd0