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>
265 lines
11 KiB
GDScript
265 lines
11 KiB
GDScript
## 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
|