From 708ab256143ae486b93f7ad7b6983b96475cc12d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Apr 2026 17:28:24 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(tests):=20sprint-37=20sweep=20=E2=80=94?= =?UTF-8?q?=20#866,=20#869,=20#870=20client=20test=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #866: Fix _escape_bbcode in dialogue_box.gd — only escape '[', not ']'. Chaining .replace(']', '[rb]') after .replace('[', '[lb]') corrupted the [lb] escape itself. Updated test_dialogue_sprint18.gd accordingly. #869: Migrate test_anti_tedium + test_bug_report_ring_buffer to instantiate BugReportDialog via .tscn (BugReportDialogScene.instantiate()) instead of bare Control.new() + set_script(). Sprint 36 moved bug_report_dialog.gd to extends MetaScreen; the old load path was an orphan .uid file returning null. #870: Delete 8 parse-error test files (test_debug_overlay_sprint19, test_entanglement_sprint22, test_fog_sprint22, test_journal_sprint18, test_minimap_sprint18, test_session_manager_sprint19, test_sprint30, test_sprite_integration) + .uid sidecars. Coverage tickets filed for fog/journal/minimap (#879, #880, #881). Sprint docs updated with #882 note. Co-Authored-By: Claude Sonnet 4.6 --- client/tests/test_debug_overlay_sprint19.gd | 317 --------- .../tests/test_debug_overlay_sprint19.gd.uid | 1 - client/tests/test_dialogue_sprint18.gd | 14 +- client/tests/test_entanglement_sprint22.gd | 238 ------- .../tests/test_entanglement_sprint22.gd.uid | 1 - client/tests/test_fog_sprint22.gd | 652 ------------------ client/tests/test_fog_sprint22.gd.uid | 1 - client/tests/test_journal_sprint18.gd | 601 ---------------- client/tests/test_journal_sprint18.gd.uid | 1 - client/tests/test_minimap_sprint18.gd | 321 --------- client/tests/test_minimap_sprint18.gd.uid | 1 - client/tests/test_session_manager_sprint19.gd | 210 ------ .../test_session_manager_sprint19.gd.uid | 1 - client/tests/test_sprint30.gd | 552 --------------- client/tests/test_sprite_integration.gd | 292 -------- client/tests/test_sprite_integration.gd.uid | 1 - client/ui/dialogue_box.gd | 5 +- docs/sprints/sprint-37/client.md | 9 + 18 files changed, 19 insertions(+), 3199 deletions(-) delete mode 100644 client/tests/test_debug_overlay_sprint19.gd delete mode 100644 client/tests/test_debug_overlay_sprint19.gd.uid delete mode 100644 client/tests/test_entanglement_sprint22.gd delete mode 100644 client/tests/test_entanglement_sprint22.gd.uid delete mode 100644 client/tests/test_fog_sprint22.gd delete mode 100644 client/tests/test_fog_sprint22.gd.uid delete mode 100644 client/tests/test_journal_sprint18.gd delete mode 100644 client/tests/test_journal_sprint18.gd.uid delete mode 100644 client/tests/test_minimap_sprint18.gd delete mode 100644 client/tests/test_minimap_sprint18.gd.uid delete mode 100644 client/tests/test_session_manager_sprint19.gd delete mode 100644 client/tests/test_session_manager_sprint19.gd.uid delete mode 100644 client/tests/test_sprint30.gd delete mode 100644 client/tests/test_sprite_integration.gd delete mode 100644 client/tests/test_sprite_integration.gd.uid diff --git a/client/tests/test_debug_overlay_sprint19.gd b/client/tests/test_debug_overlay_sprint19.gd deleted file mode 100644 index 1661dcaed..000000000 --- a/client/tests/test_debug_overlay_sprint19.gd +++ /dev/null @@ -1,317 +0,0 @@ -## Sprint 19 — Debug visualization overlay (#348) -## F3 toggle, world overlays, tick timing graph. -## Extends the existing debug_overlay.gd stub. -class_name TestDebugOverlaySprint19 -extends GdUnitTestSuite - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -const DEBUG_SCENE_PATH: String = "res://scenes/main.tscn" -const DEBUG_SCRIPT_PATH: String = "res://ui/debug_overlay.gd" - - -func _make_overlay() -> Control: - ## Instantiate a standalone DebugOverlay control for unit testing. - ## Does not require the full main.tscn scene tree. - var script := load(DEBUG_SCRIPT_PATH) - if script == null: - push_warning("TestDebugOverlaySprint19: debug_overlay.gd not found — skip") - return null - var node := Control.new() - node.set_script(script) - add_child(node) - return node - - -# --------------------------------------------------------------------------- -# Lifecycle -# --------------------------------------------------------------------------- - -func before_test() -> void: - GameState.visible_entities = [] - GameState.visible_tiles = [] - GameState.player_position = Vector2(10.0, 10.0) - GameState.player_facing = "North" - GameState.player_stance = "Walk" - GameState.current_tick = 1 - GameState.player_knowledge = null - - -func after_test() -> void: - GameState.visible_entities = [] - GameState.player_knowledge = null - - -# --------------------------------------------------------------------------- -# Script existence -# --------------------------------------------------------------------------- - -func test_debug_overlay_script_exists() -> void: - assert_bool(ResourceLoader.exists(DEBUG_SCRIPT_PATH)).override_failure_message( - "debug_overlay.gd must exist at res://ui/debug_overlay.gd (#348)" - ).is_true() - - -# --------------------------------------------------------------------------- -# Instantiation -# --------------------------------------------------------------------------- - -func test_debug_overlay_instantiates_without_crash() -> void: - var ol := _make_overlay() - if ol == null: return - assert_that(ol).is_not_null() - ol.queue_free() - - -func test_debug_overlay_starts_hidden() -> void: - ## Overlay starts hidden — only appears when F3 pressed. - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.visible).override_failure_message( - "DebugOverlay must start hidden (visible=false)" - ).is_false() - ol.queue_free() - - -# --------------------------------------------------------------------------- -# Dev-only guard -# --------------------------------------------------------------------------- - -func test_update_from_state_exists() -> void: - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.has_method("update_from_state")).override_failure_message( - "DebugOverlay must have update_from_state() method" - ).is_true() - ol.queue_free() - - -func test_update_from_state_does_not_crash_when_hidden() -> void: - ## update_from_state() called while hidden must not crash. - var ol := _make_overlay() - if ol == null: return - ol.visible = false - ol.update_from_state() # Should be a no-op, no crash - ol.queue_free() - - -func test_update_from_state_does_not_crash_when_visible() -> void: - var ol := _make_overlay() - if ol == null: return - ol.visible = true - # Simulate a minimal snapshot tick - GameState.current_tick = 42 - ol.update_from_state() - ol.queue_free() - - -# --------------------------------------------------------------------------- -# NPC path tracking -# --------------------------------------------------------------------------- - -func test_npc_paths_field_exists() -> void: - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.has("_npc_paths")).override_failure_message( - "DebugOverlay must have _npc_paths field for NPC movement history" - ).is_true() - ol.queue_free() - - -func test_npc_paths_updated_on_state_update() -> void: - ## After update_from_state with an NPC entity, _npc_paths should have an entry. - var ol := _make_overlay() - if ol == null: return - ol.visible = true - - GameState.visible_entities = [{ - "entity_id": 2, - "x": 12.0, "y": 9.0, "z": 0, - "kind": {"variant": "Npc", "data": null}, - "relationship": "Unknown", - }] - GameState.current_tick = 100 - ol.update_from_state() - assert_int(ol._npc_paths.size()).override_failure_message( - "_npc_paths must record NPC positions from visible_entities" - ).is_greater(0) - ol.queue_free() - - -func test_npc_paths_not_populated_for_player_entity() -> void: - ## Player entities must not appear in NPC path history. - var ol := _make_overlay() - if ol == null: return - ol.visible = true - GameState.visible_entities = [{ - "entity_id": 1, - "x": 10.0, "y": 10.0, "z": 0, - "kind": {"variant": "Player", "data": null}, - }] - GameState.current_tick = 101 - ol.update_from_state() - assert_int(ol._npc_paths.size()).override_failure_message( - "Player entity must not appear in _npc_paths" - ).is_equal(0) - ol.queue_free() - - -func test_npc_paths_max_length_respected() -> void: - ## Path history must not grow beyond NPC_HISTORY_LEN entries. - var ol := _make_overlay() - if ol == null: return - ol.visible = true - # Simulate NPC moving each tick — inject 20 ticks of movement - for i in range(20): - GameState.visible_entities = [{ - "entity_id": 5, - "x": float(12 + i), "y": 9.0, "z": 0, - "kind": {"variant": "Npc", "data": null}, - "relationship": "Unknown", - }] - GameState.current_tick = 200 + i - ol.update_from_state() - var path: Array = ol._npc_paths.get(5, []) - assert_int(path.size()).override_failure_message( - "NPC path must not exceed NPC_HISTORY_LEN entries (cap at %d)" % ol.NPC_HISTORY_LEN - ).is_less_equal(ol.NPC_HISTORY_LEN) - ol.queue_free() - - -# --------------------------------------------------------------------------- -# Tick timing ring -# --------------------------------------------------------------------------- - -func test_tick_deltas_field_exists() -> void: - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.has("_tick_deltas")).override_failure_message( - "DebugOverlay must have _tick_deltas field for timing sparkline" - ).is_true() - ol.queue_free() - - -func test_tick_deltas_accumulate_over_state_updates() -> void: - ## Each new tick snapshot should add a delta to _tick_deltas. - var ol := _make_overlay() - if ol == null: return - ol.visible = true - for i in range(5): - GameState.current_tick = 300 + i - ol.update_from_state() - assert_int(ol._tick_deltas.size()).override_failure_message( - "_tick_deltas must accumulate entries from successive ticks" - ).is_greater(0) - ol.queue_free() - - -func test_tick_deltas_max_length_respected() -> void: - ## _tick_deltas must not grow beyond TICK_HISTORY_LEN. - var ol := _make_overlay() - if ol == null: return - ol.visible = true - for i in range(50): - GameState.current_tick = 400 + i - ol.update_from_state() - assert_int(ol._tick_deltas.size()).override_failure_message( - "_tick_deltas must not exceed TICK_HISTORY_LEN entries" - ).is_less_equal(ol.TICK_HISTORY_LEN) - ol.queue_free() - - -# --------------------------------------------------------------------------- -# Constants defined -# --------------------------------------------------------------------------- - -func test_npc_history_len_constant_exists() -> void: - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.has("NPC_HISTORY_LEN")).override_failure_message( - "DebugOverlay must have NPC_HISTORY_LEN constant" - ).is_true() - ol.queue_free() - - -func test_tick_history_len_constant_exists() -> void: - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.has("TICK_HISTORY_LEN")).override_failure_message( - "DebugOverlay must have TICK_HISTORY_LEN constant" - ).is_true() - ol.queue_free() - - -func test_tick_warn_ms_constant_defined() -> void: - var ol := _make_overlay() - if ol == null: return - assert_bool(ol.has("TICK_WARN_MS")).override_failure_message( - "DebugOverlay must have TICK_WARN_MS constant for sparkline warning threshold" - ).is_true() - ol.queue_free() - - -# --------------------------------------------------------------------------- -# Facing angle helper -# --------------------------------------------------------------------------- - -func test_facing_to_angle_north() -> void: - ## North = -PI/2 in Godot 2D (up on screen) - var angle := _fetch_facing_angle("North") - assert_float(angle).override_failure_message( - "_facing_to_angle('North') must return -PI/2" - ).is_equal_approx(-PI / 2.0, 0.001) - - -func test_facing_to_angle_east() -> void: - var angle := _fetch_facing_angle("East") - assert_float(angle).is_equal_approx(0.0, 0.001) - - -func test_facing_to_angle_south() -> void: - var angle := _fetch_facing_angle("South") - assert_float(angle).is_equal_approx(PI / 2.0, 0.001) - - -func test_facing_to_angle_west() -> void: - var angle := _fetch_facing_angle("West") - assert_float(angle).is_equal_approx(PI, 0.001) - - -func _fetch_facing_angle(facing: String) -> float: - ## Helper: load script and call static method. - var script = load(DEBUG_SCRIPT_PATH) - if script == null: - return 0.0 - # In GDScript 4, static methods can be called via an instance - var tmp := Control.new() - tmp.set_script(script) - add_child(tmp) - var result := tmp._facing_to_angle(facing) - tmp.queue_free() - return result - - -# --------------------------------------------------------------------------- -# In-scene placement: DebugOverlay on UILayer -# --------------------------------------------------------------------------- - -func test_debug_overlay_in_main_scene_ui_layer() -> void: - ## DebugOverlay must be in UILayer (CanvasLayer 20), not InsertOverlay. - if not ResourceLoader.exists("res://scenes/main.tscn"): - push_warning("TestDebugOverlaySprint19: main.tscn not found — skip") - return - var scene: Node = load("res://scenes/main.tscn").instantiate() - auto_free(scene) - add_child(scene) - var ui_layer := scene.get_node_or_null("UILayer") - assert_that(ui_layer != null).override_failure_message( - "UILayer must exist in main.tscn" - ).is_true() - if ui_layer == null: return - var overlay := ui_layer.get_node_or_null("DebugOverlay") - assert_that(overlay != null).override_failure_message( - "DebugOverlay must be a child of UILayer in main.tscn (#348)" - ).is_true() diff --git a/client/tests/test_debug_overlay_sprint19.gd.uid b/client/tests/test_debug_overlay_sprint19.gd.uid deleted file mode 100644 index 45339eedc..000000000 --- a/client/tests/test_debug_overlay_sprint19.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bdsgybncfyu52 diff --git a/client/tests/test_dialogue_sprint18.gd b/client/tests/test_dialogue_sprint18.gd index d0ca7bcff..df8336657 100644 --- a/client/tests/test_dialogue_sprint18.gd +++ b/client/tests/test_dialogue_sprint18.gd @@ -392,18 +392,16 @@ func test_gamestate_examine_result_entity_id_survives_roundtrip() -> void: ## Note: dialogue_box.gd has no class_name — call _escape_bbcode via instance. # --------------------------------------------------------------------------- -func skip_test_escape_bbcode_brackets_in_server_text() -> void: +func test_escape_bbcode_brackets_in_server_text() -> void: ## _escape_bbcode must convert '[' to '[lb]' to prevent BBCode injection. - ## BROKEN (#866): chained replace('[', '[lb]').replace(']', '[rb]') corrupts the - ## [lb] escape — result is [lb[rb]...] instead of [lb]...]]. Bug filed. - ## Regression test: a malicious NPC name like "[wave]Evil[/wave]" must render - ## as plain text in the dialogue log. + ## Fix (#866): only escape '[' — unmatched ']' renders as a literal in RichTextLabel. + ## Exact expected output: "[lb]wave]Evil NPC[lb]/wave]" + ## RichTextLabel interprets [lb] as literal '[', and bare ']' as literal ']', + ## so the rendered output is the plain string "[wave]Evil NPC[/wave]" — no BBCode parsed. var box := _make_dialogue_box() if box == null: return var escaped: String = box._escape_bbcode("[wave]Evil NPC[/wave]") - assert_that(escaped).is_not_equal("[wave]Evil NPC[/wave]") - assert_that(escaped).contains("[lb]") - assert_bool(escaped.begins_with("[")).is_false() + assert_that(escaped).is_equal("[lb]wave]Evil NPC[lb]/wave]") box.queue_free() diff --git a/client/tests/test_entanglement_sprint22.gd b/client/tests/test_entanglement_sprint22.gd deleted file mode 100644 index c0b69cdb9..000000000 --- a/client/tests/test_entanglement_sprint22.gd +++ /dev/null @@ -1,238 +0,0 @@ -## Sprint 22 — Entanglement ratio configuration acceptance tests (#175, #178) -## -## Test-first stubs for the client-side surface of the world_seed feature. -## These tests will warn-and-skip until the implementation lands (Tyre, #175). -## -## Client-side acceptance criteria (#175): -## - GameState carries a world_seed field (stores the seed for this session) -## - SessionManager.new_game() generates and stores a world_seed -## - The IPC startup payload carries world_seed so the server can seed SimRng -## -## Server-side acceptance criteria (#178) are in: -## - server/src/content/entanglement.rs (Rust unit tests) -## -## Spec: D-029 (30/50/20 entanglement ratio, variable per seed), D-010 (deterministic sim) -## Tickets: #175, #178 -class_name TestEntanglementSprint22 -extends GdUnitTestSuite - - -# -- Client-side: GameState.world_seed field (#175) --------------------------- - -func test_game_state_has_world_seed_field() -> void: - # #175 client-side: GameState must store the world_seed for this session. - # The seed is set by SessionManager.new_game() and read by SimBridge to - # carry it in the session startup IPC message. - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed not found — test-first stub (awaiting #175)") - return - # Field exists — verify it is numeric (int or null are both acceptable initial states) - var seed_val = GameState.get("world_seed") - assert_bool(seed_val == null or seed_val is int).override_failure_message( - "GameState.world_seed must be int or null" - ).is_true() - - -func test_game_state_world_seed_can_be_set_and_read() -> void: - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped (#175 not yet implemented)") - return - var orig = GameState.get("world_seed") - GameState.world_seed = 0xDEADBEEF - assert_int(GameState.world_seed).is_equal(0xDEADBEEF) - # Restore - GameState.world_seed = orig - - -func test_game_state_world_seed_default_is_null_or_zero() -> void: - # Before a session starts, world_seed should be null (no session) or 0 (unset). - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed missing — skipped") - return - var seed_val = GameState.get("world_seed") - assert_bool(seed_val == null or seed_val == 0).override_failure_message( - "GameState.world_seed should be null or 0 before any session starts" - ).is_true() - - -# -- Client-side: SessionManager seed generation (#175) ----------------------- - -func test_session_manager_exists() -> void: - var sm = get_node_or_null("/root/SessionManager") - if sm == null: - push_warning("TestEntanglementSprint22: SessionManager autoload not found — skipped") - return - assert_that(sm).is_not_null() - - -func test_session_manager_new_game_generates_world_seed() -> void: - # #175: new_game() must generate and store world_seed in GameState. - # The seed is a non-zero u64 that will be sent to the server on startup. - var sm = get_node_or_null("/root/SessionManager") - if sm == null: - push_warning("TestEntanglementSprint22: SessionManager not found — skipped") - return - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub") - return - - # Call new_game() (will create a save dir — acceptable in test environment) - var orig_seed = GameState.get("world_seed") - var orig_game_id: String = GameState.current_game_id - sm.new_game() - var generated_seed = GameState.get("world_seed") - - # world_seed must have been set to a non-null, non-zero value - assert_bool(generated_seed != null).override_failure_message( - "SessionManager.new_game() must set GameState.world_seed (#175)" - ).is_true() - if generated_seed != null: - assert_bool(generated_seed != 0).override_failure_message( - "Generated world_seed must be non-zero" - ).is_true() - - # Restore state - GameState.current_game_id = orig_game_id - GameState.world_seed = orig_seed - - -func test_session_manager_same_game_id_has_same_seed() -> void: - # Resuming a session must restore the original world_seed (not generate a new one). - # This ensures deterministic replays work correctly (D-010). - var sm = get_node_or_null("/root/SessionManager") - if sm == null: - push_warning("TestEntanglementSprint22: SessionManager not found — skipped") - return - if not sm.has_method("resume_game"): - push_warning("TestEntanglementSprint22: resume_game() missing — skipped") - return - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub") - return - - # Set a known seed and game_id, then resume — seed must not be clobbered - GameState.world_seed = 12345678 - var orig_game_id: String = GameState.current_game_id - sm.resume_game("20260228-120000-abc123") - # resume_game() must NOT overwrite world_seed - assert_int(GameState.world_seed).override_failure_message( - "resume_game() must not overwrite world_seed — seed is loaded from the save, not regenerated" - ).is_equal(12345678) - GameState.current_game_id = orig_game_id - - -# -- IPC startup message: world_seed field (#175) ---------------------------- - -func test_protocol_encode_startup_message_has_world_seed_field() -> void: - # #175 acceptance: startup IPC message must carry "world_seed" key. - # Verifies Protocol.encode_startup_message encodes the seed so the server - # can deserialize it as StartupMessage { world_seed: u64 }. - var seed: int = 0xDEADBEEF # 3735928559 — fits in u32, safely maps to Rust u64 - var bytes: PackedByteArray = Protocol.encode_startup_message(seed) - assert_bool(bytes.size() > 0).override_failure_message( - "Protocol.encode_startup_message must return non-empty bytes" - ).is_true() - var decoded = Messagepack.decode(bytes) - assert_that(decoded.status).override_failure_message( - "encode_startup_message output must be valid msgpack: %s" % str(decoded.status) - ).is_null() - var msg = decoded.value - assert_bool(msg is Dictionary and msg.has("world_seed")).override_failure_message( - "StartupMessage wire payload must contain 'world_seed' key, got: %s" % str(msg) - ).is_true() - assert_int(msg["world_seed"]).override_failure_message( - "world_seed must round-trip through msgpack unchanged" - ).is_equal(seed) - - -func test_protocol_encode_startup_message_zero_seed() -> void: - # Edge case: seed=0 must still encode a valid payload (world_seed: 0). - var bytes: PackedByteArray = Protocol.encode_startup_message(0) - assert_bool(bytes.size() > 0).is_true() - var decoded = Messagepack.decode(bytes) - assert_that(decoded.status).is_null() - assert_int(decoded.value["world_seed"]).is_equal(0) - - -func test_sim_bridge_can_send_world_seed_in_startup() -> void: - # #175 acceptance: "startup IPC message carries a world_seed field" - # The client must be able to include world_seed in the session startup payload. - # Test-first: verify the API exists (method or field), else warn-and-skip. - var sim_bridge = get_node_or_null("/root/SimBridge") - if sim_bridge == null: - push_warning("TestEntanglementSprint22: SimBridge not found — skipped") - return - - # Option A: SimBridge has a world_seed property that is sent during startup - if "world_seed" in sim_bridge: - sim_bridge.world_seed = 99999 - assert_int(sim_bridge.world_seed).is_equal(99999) - sim_bridge.world_seed = 0 - return - - # Option B: SimBridge has a set_world_seed() method - if sim_bridge.has_method("set_world_seed"): - # Method exists — this is the expected API - sim_bridge.set_world_seed(99999) - return - - # Neither found — test-first stub - push_warning( - "TestEntanglementSprint22: SimBridge has no world_seed field or set_world_seed() — " + - "test-first stub awaiting #175 implementation" - ) - - -# -- Protocol: world_seed flows from client to server (#175) ------------------ - -func test_apply_snapshot_does_not_clobber_world_seed() -> void: - # world_seed is set at session start and must persist across all subsequent snapshots. - # Snapshots must not overwrite or clear the world_seed that was set at startup. - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub") - return - GameState.world_seed = 42000 - GameState.apply_snapshot({"tick": 5, "visible_tiles": []}) - assert_int(GameState.world_seed).override_failure_message( - "apply_snapshot() must not clear or overwrite world_seed — seed is set once at session start" - ).is_equal(42000) - GameState.world_seed = null - - -# -- Seed variation property (#178, informational — full test is Rust-side) --- - -func test_different_seeds_produce_different_configs_informational() -> void: - # D-029: "entanglement rate varies per seed to prevent metagaming calibration" - # The definitive acceptance test for this is Rust-side (server/src/content/entanglement.rs): - # - EntanglementConfig::from_rng(seed_A) == EntanglementConfig::from_rng(seed_A) [deterministic] - # - EntanglementConfig::from_rng(seed_A) != EntanglementConfig::from_rng(seed_B) [variable, >=90%] - # - # This test only verifies the client side: world_seed is a u64 large enough to - # have sufficient entropy. A 24-bit game_id hex component alone has 16M combinations; - # the full u64 seed provides 2^64 possibilities. - # - # We verify that two calls to new_game() produce different seeds. - var sm = get_node_or_null("/root/SessionManager") - if sm == null: - push_warning("TestEntanglementSprint22: SessionManager not found — skipped") - return - if not "world_seed" in GameState: - push_warning("TestEntanglementSprint22: GameState.world_seed missing — test-first stub") - return - - var orig_game_id: String = GameState.current_game_id - sm.new_game() - var seed_a = GameState.get("world_seed") - sm.new_game() - var seed_b = GameState.get("world_seed") - - if seed_a == null or seed_b == null: - push_warning("TestEntanglementSprint22: new_game() did not set world_seed — test-first stub") - GameState.current_game_id = orig_game_id - return - - # Two different sessions should produce different seeds - assert_bool(seed_a != seed_b).override_failure_message( - "Two calls to new_game() must produce different world_seeds (D-029 anti-metagaming)" - ).is_true() - GameState.current_game_id = orig_game_id diff --git a/client/tests/test_entanglement_sprint22.gd.uid b/client/tests/test_entanglement_sprint22.gd.uid deleted file mode 100644 index bad8ed8cd..000000000 --- a/client/tests/test_entanglement_sprint22.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c1dnlbnxtgqqo diff --git a/client/tests/test_fog_sprint22.gd b/client/tests/test_fog_sprint22.gd deleted file mode 100644 index 3f851298c..000000000 --- a/client/tests/test_fog_sprint22.gd +++ /dev/null @@ -1,652 +0,0 @@ -## Sprint 22 — Fog system acceptance tests (#569) -## -## Validates FogState data management against the Sprint 22 acceptance criteria: -## - Explored tiles never revert to unexplored black (EXP_EXPLORED persistence) -## - Bounds grow-only invariant (explored tiles behind player stay in texture) -## - All visible tiles written as Forward (server simplified to Forward-only) -## - Exploration data survives texture resize (grow-only bounds copy) -## - Shader file present with correct fog_alpha constant -## -## Spec: D-059 (fog shader), D-015 (vision cone), D-066 (dual-scale grid, 6-8 tile gradient) -## Ticket: #569 -class_name TestFogSprint22 -extends GdUnitTestSuite - - -func _get_fog_state() -> Node: - var node = get_node_or_null("/root/FogState") - if node == null: - push_warning("TestFogSprint22: FogState autoload not found — test skipped (awaiting #569)") - return node - - -func before_test() -> void: - GameState.visible_positions.clear() - GameState.visible_tiles.clear() - GameState.visibility_sectors.clear() - GameState.boundary_positions.clear() - - -func after_test() -> void: - GameState.visible_positions.clear() - GameState.visible_tiles.clear() - GameState.visibility_sectors.clear() - GameState.boundary_positions.clear() - - -# -- Spec constants (D-059) --------------------------------------------------- - -func test_exp_explored_constant_is_128() -> void: - # EXP_EXPLORED = 128 → shader reads this as ~0.502. - # smoothstep(0.0, 0.2, 0.502) = 1.0 → exp_fade fully applied. - # If EXP_EXPLORED were 0, explored tiles would render as solid unexplored black. - var fog_state = _get_fog_state() - if fog_state == null: - return - assert_int(fog_state.EXP_EXPLORED).override_failure_message( - "EXP_EXPLORED must be 128 — shader exp_fade requires explored value > 0.2 to avoid unexplored-black rendering" - ).is_equal(128) - - -func test_exp_unexplored_constant_is_0() -> void: - var fog_state = _get_fog_state() - if fog_state == null: - return - assert_int(fog_state.EXP_UNEXPLORED).is_equal(0) - - -func test_exp_visible_constant_is_255() -> void: - # EXP_VISIBLE = 255 → shader reads 1.0, full art visibility (currently in LOS) - var fog_state = _get_fog_state() - if fog_state == null: - return - assert_int(fog_state.EXP_VISIBLE).is_equal(255) - - -func test_vis_forward_constant_is_255() -> void: - # D-059: VIS_FORWARD = 255 → clear vision, nearly transparent fog overlay - var fog_state = _get_fog_state() - if fog_state == null: - return - assert_int(fog_state.VIS_FORWARD).is_equal(255) - - -func test_vis_hidden_constant_is_0() -> void: - # D-059: VIS_HIDDEN = 0 → no vision, fog fully opaque - var fog_state = _get_fog_state() - if fog_state == null: - return - assert_int(fog_state.VIS_HIDDEN).is_equal(0) - - -func test_unexplored_color_spec_value() -> void: - # D-059: Unexplored = solid near-black #12141a - # Verify the hex value decodes to the expected channel values. - var c := Color("#12141a") - assert_float(c.r).is_equal_approx(18.0 / 255.0, 0.003) - assert_float(c.g).is_equal_approx(20.0 / 255.0, 0.003) - assert_float(c.b).is_equal_approx(26.0 / 255.0, 0.003) - # Sanity: it IS very dark (all channels < 0.12) - assert_float(c.r).is_less(0.12) - assert_float(c.g).is_less(0.12) - assert_float(c.b).is_less(0.12) - - -# -- Acceptance: explored tiles persist after leaving LOS (criterion 3) ------ - -func test_explored_tile_becomes_exp_explored_after_leaving_los() -> void: - # ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black" - # When tile (5,5) was in LOS (frame 1) and then leaves LOS (frame 2), - # its exploration byte must be EXP_EXPLORED (128), not EXP_UNEXPLORED (0). - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - push_warning("TestFogSprint22: update_from_state missing — skipped") - return - - # Frame 1: tile (5,5) is visible - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - - # Frame 2: tile (5,5) leaves LOS - GameState.visible_positions.clear() - GameState.visible_tiles = [] - fog_state.update_from_state() - - # Internal state check: _exp_bytes[tile(5,5)] must be EXP_EXPLORED (128) - var exp_bytes = fog_state.get("_exp_bytes") - if exp_bytes == null: - push_warning("TestFogSprint22: _exp_bytes not accessible — data path untestable headlessly") - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - push_warning("TestFogSprint22: _width inaccessible — data path untestable") - return - var px := 5 - ox - var py := 5 - oy - if px < 0 or py < 0 or px >= w: - push_warning("TestFogSprint22: tile (5,5) out of bounds after update — check grow_bounds margin") - return - var idx := py * w + px - if idx < 0 or idx >= exp_bytes.size(): - push_warning("TestFogSprint22: idx %d out of exp_bytes range %d" % [idx, exp_bytes.size()]) - return - assert_int(exp_bytes[idx]).override_failure_message( - "Tile (5,5) must be EXP_EXPLORED=128 after leaving LOS — not EXP_UNEXPLORED=0 (#569 regression)" - ).is_equal(fog_state.EXP_EXPLORED) - - -func test_explored_tile_is_exp_visible_while_in_los() -> void: - # While in LOS, tile exploration byte must be EXP_VISIBLE (255) - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - GameState.visible_positions = {Vector2i(3, 3): true} - GameState.visible_tiles = [{"x": 3, "y": 3, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - - var exp_bytes = fog_state.get("_exp_bytes") - if exp_bytes == null: - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - var px := 3 - ox - var py := 3 - oy - if px < 0 or py < 0 or px >= w: - return - var idx := py * w + px - if idx >= 0 and idx < exp_bytes.size(): - assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_VISIBLE) - - -func test_unexplored_tile_stays_exp_unexplored() -> void: - # Tile (7, 8) was never seen — must remain EXP_UNEXPLORED (0) - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - # See only (5, 5) — tile (7, 8) is not in LOS - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - - var exp_bytes = fog_state.get("_exp_bytes") - if exp_bytes == null: - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - var px := 7 - ox - var py := 8 - oy - if px < 0 or py < 0 or px >= w: - return - var idx := py * w + px - if idx >= 0 and idx < exp_bytes.size(): - assert_int(exp_bytes[idx]).is_equal(fog_state.EXP_UNEXPLORED) - - -# -- Acceptance: bounds grow-only invariant ------------------------------------ - -func test_bounds_never_shrink() -> void: - # ACCEPTANCE CRITERION: "Explored tiles never revert to unexplored black" - # Requires grow-only bounds: once a tile is in the texture, it stays there. - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - # Frame 1: see (10, 10) → establishes initial bounds - GameState.visible_positions = {Vector2i(10, 10): true} - GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - var b1: Rect2i = fog_state.map_bounds - - # Frame 2: see (30, 30) → bounds must expand to include both - GameState.visible_positions = {Vector2i(30, 30): true} - GameState.visible_tiles = [{"x": 30, "y": 30, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - var b2: Rect2i = fog_state.map_bounds - - # Frame 3: back to (10, 10) → bounds must NOT shrink - GameState.visible_positions = {Vector2i(10, 10): true} - GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - var b3: Rect2i = fog_state.map_bounds - - assert_bool(b2.size.x >= b1.size.x).override_failure_message( - "Bounds must grow when player moves to larger region" - ).is_true() - assert_bool(b2.size.y >= b1.size.y).is_true() - assert_bool(b3.size.x >= b2.size.x).override_failure_message( - "Bounds must not shrink when player returns to previous position (grow-only invariant)" - ).is_true() - assert_bool(b3.size.y >= b2.size.y).is_true() - - -func test_bounds_include_margin_for_gradient_bleed() -> void: - # D-066: 6-8 tile gradient at cone edge requires texture margin. - # _grow_bounds adds 8-tile margin on each side (accommodates 7x7 Gaussian - # kernel at 2-texel intervals = ±6 tile reach). After seeing (10,10), - # bounds should extend at least 4 tiles beyond the visible tile. - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - GameState.visible_positions = {Vector2i(10, 10): true} - GameState.visible_tiles = [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - - var b: Rect2i = fog_state.map_bounds - # With 4-tile margin: bounds.position.x <= 10 - 4 = 6 - assert_bool(b.position.x <= 6).override_failure_message( - "FogState bounds must include 4-tile margin for gradient bleed (D-066 gradient spec)" - ).is_true() - assert_bool(b.position.y <= 6).is_true() - - -# -- Acceptance: Forward-only visibility (Sprint 22 server simplification) ---- - -func test_visible_tiles_written_as_vis_forward() -> void: - # Sprint 22: server sends only Forward tiles (Peripheral sector removed). - # FogState writes VIS_FORWARD (255) for all tiles in visible_positions. - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true} - GameState.visible_tiles = [ - {"x": 5, "y": 5, "z": 0, "visibility": "Forward"}, - {"x": 6, "y": 5, "z": 0, "visibility": "Forward"}, - ] - fog_state.update_from_state() - - var vis_bytes = fog_state.get("_vis_bytes") - if vis_bytes == null: - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - for pos in [Vector2i(5, 5), Vector2i(6, 5)]: - var px := pos.x - ox - var py := pos.y - oy - if px < 0 or py < 0 or px >= w: - continue - var idx := py * w + px - if idx >= 0 and idx < vis_bytes.size(): - assert_int(vis_bytes[idx]).override_failure_message( - "All visible tiles should be VIS_FORWARD=255 — server is Forward-only in Sprint 22" - ).is_equal(fog_state.VIS_FORWARD) - - -func test_tiles_outside_los_written_as_vis_hidden() -> void: - # Tiles in bounds but not in visible_positions must be VIS_HIDDEN (0) - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - - # (5, 7) is inside the padded bounds but not visible — must be VIS_HIDDEN - var vis_bytes = fog_state.get("_vis_bytes") - if vis_bytes == null: - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - var px := 5 - ox - var py := 7 - oy - if px >= 0 and py >= 0 and px < w: - var idx := py * w + px - if idx >= 0 and idx < vis_bytes.size(): - assert_int(vis_bytes[idx]).is_equal(fog_state.VIS_HIDDEN) - - -# -- Acceptance: exploration survives texture resize -------------------------- - -func test_exploration_data_preserved_across_bounds_growth() -> void: - # D-059: Texture resize must copy old exploration bytes into new texture. - # Without this, tiles seen before a resize appear as EXP_UNEXPLORED (black). - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - # Frame 1: see (5, 5), then leave - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.visible_tiles = [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - GameState.visible_positions.clear() - GameState.visible_tiles = [] - fog_state.update_from_state() # (5,5) → EXP_EXPLORED - - # Frame 2: move far away — forces bounds growth (resize) - GameState.visible_positions = {Vector2i(80, 80): true} - GameState.visible_tiles = [{"x": 80, "y": 80, "z": 0, "visibility": "Forward"}] - fog_state.update_from_state() - - # (5,5) must still be EXP_EXPLORED after the resize - var exp_bytes = fog_state.get("_exp_bytes") - if exp_bytes == null: - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - var px := 5 - ox - var py := 5 - oy - if px < 0 or py < 0 or px >= w: - push_warning("TestFogSprint22: (5,5) not in bounds after resize — is copy-on-resize working?") - return - var idx := py * w + px - if idx >= 0 and idx < exp_bytes.size(): - assert_int(exp_bytes[idx]).override_failure_message( - "Exploration data at (5,5) must survive bounds growth — EXP_EXPLORED (128) expected after resize" - ).is_greater_equal(fog_state.EXP_EXPLORED) - - -# -- Shader file checks (D-059) ----------------------------------------------- - -func test_fog_gdshader_exists() -> void: - assert_bool(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message( - "fog.gdshader must exist — fog rendering requires this shader file (#569)" - ).is_true() - - -func test_fog_shader_defines_fog_alpha() -> void: - # D-059: explored fog overlay must be ~25-30% opacity. - # fog_alpha constant controls this. Verify the shader defines it. - if not ResourceLoader.exists("res://shaders/fog.gdshader"): - push_warning("TestFogSprint22: fog.gdshader not found — shader check skipped") - return - var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") - if source.is_empty(): - push_warning("TestFogSprint22: fog.gdshader is empty or unreadable") - return - assert_bool(source.contains("fog_alpha")).override_failure_message( - "fog.gdshader must define fog_alpha for the 25-30%% explored-tile overlay (D-059)" - ).is_true() - - -func test_fog_shader_defines_smoothstep_clarity_ramp() -> void: - # D-059/D-066: smooth gradient requires a clarity ramp (smoothstep). - # The blurred visibility → clarity ramp must use smoothstep for smooth gradients. - if not ResourceLoader.exists("res://shaders/fog.gdshader"): - push_warning("TestFogSprint22: fog.gdshader not found — skipped") - return - var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") - if source.is_empty(): - return - assert_bool(source.contains("smoothstep")).override_failure_message( - "fog.gdshader must use smoothstep for the clarity ramp — hard steps violate D-066 gradient spec" - ).is_true() - - -func test_fog_shader_defines_unexplored_color() -> void: - # D-059: unexplored = solid near-black #12141a. - if not ResourceLoader.exists("res://shaders/fog.gdshader"): - push_warning("TestFogSprint22: fog.gdshader not found — skipped") - return - var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") - if source.is_empty(): - return - assert_bool(source.contains("UNEXPLORED_COLOR")).override_failure_message( - "fog.gdshader must define UNEXPLORED_COLOR constant (D-059 #12141a spec)" - ).is_true() - - -func test_fog_shader_uses_gaussian_blur_for_gradient() -> void: - # D-066: 6-8 tile soft gradient requires Gaussian blur on visibility texture. - # Current implementation: 7x7 kernel at 2-texel intervals (±6 tiles), sigma 2.0 - # in kernel space = 4.0 tiles effective. At 2-sigma (8 tiles), weight drops to 0.14. - # This covers the D-066 "6-8 tile" gradient spec. - if not ResourceLoader.exists("res://shaders/fog.gdshader"): - push_warning("TestFogSprint22: fog.gdshader not found — skipped") - return - var source := FileAccess.get_file_as_string("res://shaders/fog.gdshader") - if source.is_empty(): - return - # 7x7 Gaussian uses dy from -3 to 3 - assert_bool(source.contains("sample_visibility")).override_failure_message( - "fog.gdshader must call sample_visibility() for Gaussian-blurred visibility (D-066 gradient)" - ).is_true() - assert_bool(source.contains("-3.0")).override_failure_message( - "fog.gdshader sample_visibility must use 7x7 kernel (±3 tiles) for 6-tile gradient coverage (D-066)" - ).is_true() - - -# -- Regression: GameState visible_positions (existing contract) --------------- - -func test_visible_positions_derived_from_visible_tiles_in_server_mode() -> void: - # D-020: In real server mode, visible_positions derives from visible_tiles. - # Fog rendering depends on this derivation being correct. - GameState.apply_snapshot({ - "tick": 10, - "visible_tiles": [ - {"x": 7, "y": 7, "z": 0, "visibility": "Forward"}, - {"x": 8, "y": 7, "z": 0, "visibility": "Forward"}, - ], - }) - assert_bool(GameState.visible_positions.has(Vector2i(7, 7))).override_failure_message( - "visible_positions must be derived from visible_tiles when no explicit visible_positions key" - ).is_true() - assert_bool(GameState.visible_positions.has(Vector2i(8, 7))).is_true() - - -func test_visibility_sectors_populated_forward_only() -> void: - # D-015: visibility_sectors must be populated from visible_tiles. - # In Forward-only mode, all sectors are "Forward". - GameState.apply_snapshot({ - "tick": 11, - "visible_tiles": [ - {"x": 4, "y": 4, "z": 0, "visibility": "Forward"}, - ], - }) - assert_bool(GameState.visibility_sectors.has(Vector2i(4, 4))).is_true() - assert_str(GameState.visibility_sectors[Vector2i(4, 4)]).is_equal("Forward") - - -func test_visible_positions_cleared_on_new_snapshot() -> void: - # Old positions from tick N must not persist to tick N+1 - GameState.apply_snapshot({ - "tick": 1, - "visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}], - }) - assert_int(GameState.visible_positions.size()).is_equal(1) - GameState.apply_snapshot({ - "tick": 2, - "visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}], - }) - assert_bool(GameState.visible_positions.has(Vector2i(5, 5))).override_failure_message( - "Old visible positions must be cleared when new visible_tiles arrive" - ).is_false() - assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).is_true() - - -# -- Sprint 23: BoundaryWall handling (#585) ---------------------------------- - -func test_boundary_positions_populated_from_snapshot() -> void: - # #585: BoundaryWall tiles go to boundary_positions (not visible_positions). - # Fog lifts for boundary wall tiles so wall content composites correctly. - GameState.apply_snapshot({ - "tick": 20, - "visible_tiles": [ - {"x": 10, "y": 10, "z": 0, "visibility": "Forward", "type": "floor"}, - {"x": 11, "y": 10, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, - ], - }) - assert_bool(GameState.visible_positions.has(Vector2i(10, 10))).override_failure_message( - "Forward tile must be in visible_positions" - ).is_true() - assert_bool(GameState.visible_positions.has(Vector2i(11, 10))).override_failure_message( - "BoundaryWall tile must NOT be in visible_positions (#585)" - ).is_false() - assert_bool(GameState.boundary_positions.has(Vector2i(11, 10))).override_failure_message( - "BoundaryWall tile must be in boundary_positions (#585)" - ).is_true() - - -func test_boundary_wall_vis_forward_not_exp_visible() -> void: - # #585: BoundaryWall tiles get VIS_FORWARD (fog lifted) but NOT EXP_VISIBLE. - # They render through fog but are not stored as exploration memory. - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.boundary_positions = {Vector2i(6, 5): true} - GameState.visible_tiles = [ - {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, - {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, - ] - fog_state.update_from_state() - - var vis_bytes = fog_state.get("_vis_bytes") - var exp_bytes = fog_state.get("_exp_bytes") - if vis_bytes == null or exp_bytes == null: - push_warning("TestFogSprint22: byte arrays not accessible — skipped") - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - var px := 6 - ox - var py := 5 - oy - if px < 0 or py < 0 or px >= w: - push_warning("TestFogSprint22: boundary tile (6,5) out of bounds — skipped") - return - var idx := py * w + px - if idx < 0 or idx >= vis_bytes.size(): - return - assert_int(vis_bytes[idx]).override_failure_message( - "BoundaryWall tile must have VIS_FORWARD — fog must lift to composite wall content (#585)" - ).is_equal(fog_state.VIS_FORWARD) - assert_int(exp_bytes[idx]).override_failure_message( - "BoundaryWall tile must NOT be EXP_VISIBLE — it is not explored memory (#585)" - ).is_not_equal(fog_state.EXP_VISIBLE) - - -func test_boundary_wall_stays_unexplored_after_leaving_los() -> void: - # #585: When BoundaryWall tile leaves LOS, it must NOT decay to EXP_EXPLORED. - # Normal LOS tiles decay to EXP_EXPLORED when they leave LOS. - # Boundary tiles must stay EXP_UNEXPLORED — they were never explored. - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - # Frame 1: BoundaryWall at (6,5) is visible - GameState.visible_positions = {Vector2i(5, 5): true} - GameState.boundary_positions = {Vector2i(6, 5): true} - GameState.visible_tiles = [ - {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, - {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, - ] - fog_state.update_from_state() - - # Frame 2: both leave LOS - GameState.visible_positions.clear() - GameState.boundary_positions.clear() - GameState.visible_tiles = [] - fog_state.update_from_state() - - var exp_bytes = fog_state.get("_exp_bytes") - if exp_bytes == null: - return - var ox: int = fog_state.map_bounds.position.x - var oy: int = fog_state.map_bounds.position.y - var w: int = fog_state.get("_width") if fog_state.get("_width") != null else -1 - if w <= 0: - return - var px := 6 - ox - var py := 5 - oy - if px >= 0 and py >= 0 and px < w: - var idx := py * w + px - if idx >= 0 and idx < exp_bytes.size(): - assert_int(exp_bytes[idx]).override_failure_message( - "BoundaryWall tile must stay EXP_UNEXPLORED after leaving LOS (#585 — not explored memory)" - ).is_equal(fog_state.EXP_UNEXPLORED) - - -func test_boundary_wall_cleared_on_new_snapshot() -> void: - # #585: boundary_positions must be cleared each tick — old walls must not persist. - # BoundaryWall positions shift as the player moves; stale positions would lift fog - # where no wall exists. - GameState.apply_snapshot({ - "tick": 30, - "visible_tiles": [ - {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, - {"x": 6, "y": 5, "z": 0, "visibility": "BoundaryWall", "type": "wall"}, - ], - }) - assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).is_true() - - GameState.apply_snapshot({ - "tick": 31, - "visible_tiles": [ - {"x": 5, "y": 5, "z": 0, "visibility": "Forward", "type": "floor"}, - ], - }) - assert_bool(GameState.boundary_positions.has(Vector2i(6, 5))).override_failure_message( - "Stale BoundaryWall position must be cleared on next snapshot (#585)" - ).is_false() - - -# -- Performance (D-059) ------------------------------------------------------- - -func test_fog_state_update_under_2ms_for_400_tiles() -> void: - # D-059: <1ms/frame CPU budget for fog update. Allow 2x margin for test env. - var fog_state = _get_fog_state() - if fog_state == null: - return - if not fog_state.has_method("update_from_state"): - return - - var positions: Dictionary = {} - var tiles: Array = [] - for x in range(20): - for y in range(20): - positions[Vector2i(x, y)] = true - tiles.append({"x": x, "y": y, "z": 0, "visibility": "Forward"}) - GameState.visible_positions = positions - GameState.visible_tiles = tiles - - var start := Time.get_ticks_usec() - fog_state.update_from_state() - var elapsed_ms := (Time.get_ticks_usec() - start) / 1000.0 - - assert_float(elapsed_ms).override_failure_message( - "FogState.update_from_state() must complete in <2ms for 400 tiles (spec: <1ms D-059)" - ).is_less(2.0) diff --git a/client/tests/test_fog_sprint22.gd.uid b/client/tests/test_fog_sprint22.gd.uid deleted file mode 100644 index 90787ff09..000000000 --- a/client/tests/test_fog_sprint22.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://bxhgo1e4rvfmi diff --git a/client/tests/test_journal_sprint18.gd b/client/tests/test_journal_sprint18.gd deleted file mode 100644 index ad2a5dd13..000000000 --- a/client/tests/test_journal_sprint18.gd +++ /dev/null @@ -1,601 +0,0 @@ -## Sprint 18 — Knowledge/journal display (#264) -## Spec refs: D-041 (knowledge graph data model), D-027 (vertical slice — KG display), -## D-042 (UIStrings for all labels) -## -## Tests now run against live Stig implementation. -## Wire format per game_state.gd v14: -## player_knowledge: {entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}]} -## -## NOTE: player_knowledge PERSISTS between snapshots (no-clear behavior, by design). -## The server sends KG updates only when the graph changes — absence = no change. -## Contrast with current_examine_result which DOES clear each snapshot. -class_name TestJournalSprint18 -extends GdUnitTestSuite - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -const JOURNAL_SCENE_PATH: String = "res://ui/journal_panel.tscn" - -func _make_journal_panel() -> Control: - if not ResourceLoader.exists(JOURNAL_SCENE_PATH): - push_warning("TestJournalSprint18: journal_panel.tscn not found — scene tests skipped") - return null - var node: Control = load(JOURNAL_SCENE_PATH).instantiate() - add_child(node) - return node - - -func _make_kg_entity(overrides: Dictionary = {}) -> Dictionary: - ## Wire format per game_state.gd v14 / Stig's Stig confirmation (2026-02-25). - ## entities: [{entity_id, name, confidence, source, state, relationship, last_observed_tick}] - var base: Dictionary = { - "entity_id": 42, - "name": "Kael Davan", - "confidence": "KnowsOf", - "source": "DirectObservation", - "state": "Active", - "relationship": "PersonOfInterest", - "last_observed_tick": 1024, - } - base.merge(overrides, true) - return base - - -func _make_player_knowledge(entities: Array = []) -> Dictionary: - if entities.is_empty(): - entities = [_make_kg_entity()] - return {"entities": entities} - - -func _entries_container(panel: Control) -> Node: - return panel.get_node_or_null( - "PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer" - ) - - -# --------------------------------------------------------------------------- -# Lifecycle -# --------------------------------------------------------------------------- - -func before_test() -> void: - GameState.player_knowledge = null - GameState.current_dialogue = null - GameState.dialogue_active = false - GameState.current_tick = 0 - -func after_test() -> void: - GameState.player_knowledge = null - GameState.current_dialogue = null - GameState.dialogue_active = false - - -# --------------------------------------------------------------------------- -# GameState: player_knowledge snapshot parsing -# --------------------------------------------------------------------------- - -func test_gamestate_player_knowledge_field_exists() -> void: - ## GameState must have player_knowledge field (v14, #264). - assert_bool(GameState.has("player_knowledge")).override_failure_message( - "GameState must have 'player_knowledge' field (Sprint 18 #264)" - ).is_true() - - -func test_gamestate_player_knowledge_null_by_default() -> void: - GameState.player_knowledge = null - assert_that(GameState.player_knowledge).is_null() - - -func test_gamestate_player_knowledge_set_from_snapshot() -> void: - GameState.apply_snapshot({ - "tick": 10, - "player_knowledge": _make_player_knowledge(), - }) - assert_that(GameState.player_knowledge).is_not_null() - assert_bool(GameState.player_knowledge.has("entities")).is_true() - - -func test_gamestate_player_knowledge_persists_when_absent() -> void: - ## IMPORTANT: player_knowledge does NOT clear when absent from snapshot. - ## Server sends KG updates only on change — absence means "no change since last tick". - ## This is intentional behavior (journal should not flash empty every tick). - GameState.player_knowledge = _make_player_knowledge() - GameState.apply_snapshot({"tick": 11}) - assert_that(GameState.player_knowledge).is_not_null() - - -func test_gamestate_player_knowledge_null_when_non_dict() -> void: - ## Malformed player_knowledge (non-dict) must be rejected. - ## First set a valid value, then try to overwrite with invalid - GameState.player_knowledge = _make_player_knowledge() - GameState.apply_snapshot({"tick": 1, "player_knowledge": "bad-value"}) - # Non-dict is rejected — previous value preserved (or null if first time) - # The implementation only updates on Dictionary type, so value persists - assert_that(GameState.player_knowledge).is_not_null() - - -func test_gamestate_player_knowledge_entities_survive_roundtrip() -> void: - var entities := [ - _make_kg_entity({"name": "Kael Davan", "state": "Active"}), - _make_kg_entity({"name": "Lysa Orin", "state": "Contradicted", "entity_id": 55}), - ] - GameState.apply_snapshot({"tick": 5, "player_knowledge": {"entities": entities}}) - var parsed_entities: Array = GameState.player_knowledge.get("entities", []) - assert_int(parsed_entities.size()).is_equal(2) - assert_that(parsed_entities[0].get("name")).is_equal("Kael Davan") - assert_that(parsed_entities[1].get("state")).is_equal("Contradicted") - - -func test_gamestate_player_knowledge_updated_when_new_data_arrives() -> void: - ## When server sends a new player_knowledge, it replaces the previous value. - GameState.apply_snapshot({"tick": 1, "player_knowledge": _make_player_knowledge([ - _make_kg_entity({"name": "Person A"}), - ])}) - GameState.apply_snapshot({"tick": 2, "player_knowledge": _make_player_knowledge([ - _make_kg_entity({"name": "Person A"}), - _make_kg_entity({"name": "Person B", "entity_id": 99}), - ])}) - var entities: Array = GameState.player_knowledge.get("entities", []) - assert_int(entities.size()).is_equal(2) - - -# --------------------------------------------------------------------------- -# Journal panel: scene and API -# --------------------------------------------------------------------------- - -func test_journal_panel_scene_exists() -> void: - assert_bool(ResourceLoader.exists(JOURNAL_SCENE_PATH)).override_failure_message( - "Journal panel scene must exist at res://ui/journal_panel.tscn" - ).is_true() - - -func test_journal_panel_instantiates_without_crash() -> void: - var panel := _make_journal_panel() - if panel == null: return - assert_that(panel).is_not_null() - panel.queue_free() - - -func test_journal_panel_has_toggle_method() -> void: - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.has_method("toggle")).override_failure_message( - "JournalPanel must have toggle() method" - ).is_true() - panel.queue_free() - - -func test_journal_panel_has_close_method() -> void: - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.has_method("close")).override_failure_message( - "JournalPanel must have close() method" - ).is_true() - panel.queue_free() - - -func test_journal_panel_has_is_open_method() -> void: - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.has_method("is_open")).override_failure_message( - "JournalPanel must have is_open() method" - ).is_true() - panel.queue_free() - - -func test_journal_panel_has_update_from_state_method() -> void: - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.has_method("update_from_state")).override_failure_message( - "JournalPanel must have update_from_state() method (called from main.gd)" - ).is_true() - panel.queue_free() - - -func test_journal_panel_closed_on_init() -> void: - ## Panel starts hidden — not open by default. - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.is_open()).override_failure_message( - "JournalPanel must be closed on _ready()" - ).is_false() - panel.queue_free() - - -func test_journal_panel_toggle_opens() -> void: - ## First toggle() opens the panel. - var panel := _make_journal_panel() - if panel == null: return - panel.toggle() - assert_bool(panel.is_open()).override_failure_message( - "toggle() must set is_open() = true" - ).is_true() - panel.queue_free() - - -func test_journal_panel_toggle_closes() -> void: - ## Second toggle() closes the panel. - var panel := _make_journal_panel() - if panel == null: return - panel.toggle() # open - panel.toggle() # close - assert_bool(panel.is_open()).override_failure_message( - "Second toggle() must close the panel" - ).is_false() - panel.queue_free() - - -func test_journal_panel_close_when_already_closed_is_safe() -> void: - ## close() on an already-closed panel must not crash. - var panel := _make_journal_panel() - if panel == null: return - panel.close() - assert_bool(panel.is_open()).is_false() - panel.queue_free() - - -# --------------------------------------------------------------------------- -# Journal panel: entry rendering -# --------------------------------------------------------------------------- - -func test_journal_panel_entries_container_exists() -> void: - ## EntriesContainer is the VBoxContainer that holds entity entries. - var panel := _make_journal_panel() - if panel == null: return - var container := _entries_container(panel) - assert_that(container != null).override_failure_message( - "EntriesContainer must exist at PanelContainer/MarginContainer/VBoxContainer/ScrollContainer/EntriesContainer" - ).is_true() - panel.queue_free() - - -func test_journal_panel_shows_entries_when_knowledge_populated() -> void: - ## Opening panel with player_knowledge set creates entry nodes in EntriesContainer. - var panel := _make_journal_panel() - if panel == null: return - - GameState.player_knowledge = _make_player_knowledge([ - _make_kg_entity({"name": "Kael Davan"}), - ]) - panel.toggle() # calls _show_panel() -> _rebuild_entries() - - var container := _entries_container(panel) - if container == null: panel.queue_free(); return - - assert_int(container.get_child_count()).override_failure_message( - "EntriesContainer must have children when player_knowledge is populated" - ).is_greater(0) - panel.queue_free() - - -func test_journal_panel_shows_empty_state_when_no_knowledge() -> void: - ## Empty state Label is shown when player_knowledge is null. - var panel := _make_journal_panel() - if panel == null: return - - GameState.player_knowledge = null - panel.toggle() - - var container := _entries_container(panel) - if container == null: panel.queue_free(); return - - ## Empty state = exactly 1 child (the "Nothing logged yet." label) - assert_int(container.get_child_count()).override_failure_message( - "EntriesContainer should have 1 child (empty state label) when knowledge is null" - ).is_equal(1) - panel.queue_free() - - -func test_journal_panel_two_entities_create_more_entries() -> void: - ## Two entities create more entries than one (header + detail each, plus spacers). - var panel := _make_journal_panel() - if panel == null: return - - GameState.player_knowledge = _make_player_knowledge([ - _make_kg_entity({"name": "Entity A", "entity_id": 1}), - _make_kg_entity({"name": "Entity B", "entity_id": 2}), - ]) - panel.toggle() - - var container := _entries_container(panel) - if container == null: panel.queue_free(); return - - ## Each entity: header_rtl + detail_rtl + spacer = 3 nodes. Two entities = 6 min. - assert_int(container.get_child_count()).override_failure_message( - "Two entities must create at least 6 child nodes (2 × [header + detail + spacer])" - ).is_greater_equal(6) - panel.queue_free() - - -func test_journal_panel_contradicted_entity_uses_strikethrough() -> void: - ## D-041: Contradicted entities must have strikethrough in their header BBCode. - ## journal_panel.gd renders [s]Name[/s] for Contradicted state. - var panel := _make_journal_panel() - if panel == null: return - - GameState.player_knowledge = _make_player_knowledge([ - _make_kg_entity({"name": "Bad Guy", "state": "Contradicted"}), - ]) - panel.toggle() - - var container := _entries_container(panel) - if container == null: panel.queue_free(); return - - ## First child should be the header RichTextLabel with [s]...[/s] - if container.get_child_count() == 0: - push_warning("test_journal_panel_contradicted_entity_uses_strikethrough: no entries — skip") - panel.queue_free(); return - - var first_child := container.get_child(0) - if first_child is RichTextLabel: - assert_that(first_child.text).override_failure_message( - "Contradicted entity header must contain [s] (strikethrough) BBCode" - ).contains("[s]") - panel.queue_free() - - -func test_journal_panel_active_entity_no_strikethrough() -> void: - ## Active entity must NOT have strikethrough in its header. - var panel := _make_journal_panel() - if panel == null: return - - GameState.player_knowledge = _make_player_knowledge([ - _make_kg_entity({"name": "Good Guy", "state": "Active"}), - ]) - panel.toggle() - - var container := _entries_container(panel) - if container == null: panel.queue_free(); return - - if container.get_child_count() == 0: - push_warning("test_journal_panel_active_entity_no_strikethrough: no entries — skip") - panel.queue_free(); return - - var first_child := container.get_child(0) - if first_child is RichTextLabel: - assert_bool(first_child.text.contains("[s]")).override_failure_message( - "Active entity header must NOT have strikethrough — only Contradicted gets [s]" - ).is_false() - panel.queue_free() - - -# --------------------------------------------------------------------------- -# Journal panel: mutual exclusion with dialogue -# --------------------------------------------------------------------------- - -func test_update_from_state_closes_journal_when_dialogue_active() -> void: - ## Sprint briefing: journal must close when dialogue opens. - ## update_from_state() is called from main.gd on each snapshot. - var panel := _make_journal_panel() - if panel == null: return - - panel.toggle() # open journal - assert_bool(panel.is_open()).is_true() - - GameState.dialogue_active = true - panel.update_from_state() - - assert_bool(panel.is_open()).override_failure_message( - "Journal must close when GameState.dialogue_active = true (update_from_state() called)" - ).is_false() - panel.queue_free() - - -func test_update_from_state_does_not_close_when_dialogue_inactive() -> void: - ## update_from_state() must NOT close journal when dialogue is not active. - var panel := _make_journal_panel() - if panel == null: return - - panel.toggle() # open journal - GameState.dialogue_active = false - panel.update_from_state() - - assert_bool(panel.is_open()).override_failure_message( - "Journal must stay open when dialogue is inactive" - ).is_true() - panel.queue_free() - - -# --------------------------------------------------------------------------- -# UIStrings: confidence and source label keys (D-042 — now via UIStrings) -## CONFIDENCE_LABELS and SOURCE_LABELS dicts were removed from journal_panel.gd. -## Labels now come from UIStrings: knowledge_panel.confidence_* / knowledge_panel.source_* -# --------------------------------------------------------------------------- - -func test_ui_strings_confidence_direct_exists() -> void: - ## D-042: confidence label for "Direct" tier must be in UIStrings. - assert_bool(UIStrings.has_key("knowledge_panel.confidence_direct")).override_failure_message( - "UIStrings must have 'knowledge_panel.confidence_direct' (D-042)" - ).is_true() - - -func test_ui_strings_confidence_knowsdetails_exists() -> void: - assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsdetails")).override_failure_message( - "UIStrings must have 'knowledge_panel.confidence_knowsdetails' (D-042)" - ).is_true() - - -func test_ui_strings_confidence_knowsof_exists() -> void: - assert_bool(UIStrings.has_key("knowledge_panel.confidence_knowsof")).override_failure_message( - "UIStrings must have 'knowledge_panel.confidence_knowsof' (D-042)" - ).is_true() - - -func test_ui_strings_confidence_suspects_exists() -> void: - assert_bool(UIStrings.has_key("knowledge_panel.confidence_suspects")).override_failure_message( - "UIStrings must have 'knowledge_panel.confidence_suspects' (D-042)" - ).is_true() - - -func test_ui_strings_all_confidence_keys_non_empty() -> void: - ## All four confidence label values must be non-empty strings. - var keys := [ - "knowledge_panel.confidence_direct", - "knowledge_panel.confidence_knowsdetails", - "knowledge_panel.confidence_knowsof", - "knowledge_panel.confidence_suspects", - ] - for key in keys: - if not UIStrings.has_key(key): continue - assert_bool(UIStrings.get_text(key).length() > 0).override_failure_message( - "UIStrings key '%s' must be non-empty" % key - ).is_true() - - -func test_ui_strings_source_directobservation_exists() -> void: - assert_bool(UIStrings.has_key("knowledge_panel.source_directobservation")).override_failure_message( - "UIStrings must have 'knowledge_panel.source_directobservation' (D-042)" - ).is_true() - - -func test_ui_strings_source_toldby_exists() -> void: - assert_bool(UIStrings.has_key("knowledge_panel.source_toldby")).override_failure_message( - "UIStrings must have 'knowledge_panel.source_toldby' (D-042)" - ).is_true() - - -func test_ui_strings_source_heard_exists() -> void: - assert_bool(UIStrings.has_key("knowledge_panel.source_heard")).override_failure_message( - "UIStrings must have 'knowledge_panel.source_heard' (D-042)" - ).is_true() - - -# --------------------------------------------------------------------------- -# Journal panel: _state_color() contract -# --------------------------------------------------------------------------- - -func test_state_color_contradicted_is_amber() -> void: - ## D-041: Contradicted → amber tint (ENTITY_COLOR_POI) — THE FRIEND arc surface. - var panel := _make_journal_panel() - if panel == null: return - var color: Color = panel._state_color("Contradicted") - assert_that(color).override_failure_message( - "_state_color('Contradicted') must return ENTITY_COLOR_POI (amber)" - ).is_equal(Constants.ENTITY_COLOR_POI) - panel.queue_free() - - -func test_state_color_stale_is_dimmed() -> void: - ## Stale → dimmed text color (IMPLANT_TEXT_DIM). - var panel := _make_journal_panel() - if panel == null: return - var color: Color = panel._state_color("Stale") - assert_that(color).override_failure_message( - "_state_color('Stale') must return IMPLANT_TEXT_DIM" - ).is_equal(Constants.IMPLANT_TEXT_DIM) - panel.queue_free() - - -func test_state_color_active_is_normal() -> void: - ## Active → normal insert text color (INSERT_COLOR_TEXT). - var panel := _make_journal_panel() - if panel == null: return - var color: Color = panel._state_color("Active") - assert_that(color).override_failure_message( - "_state_color('Active') must return INSERT_COLOR_TEXT" - ).is_equal(Constants.INSERT_COLOR_TEXT) - panel.queue_free() - - -func test_state_color_contradicted_differs_from_active() -> void: - ## Contradicted and Active must have visually distinct colors. - var panel := _make_journal_panel() - if panel == null: return - var contradicted := panel._state_color("Contradicted") - var active := panel._state_color("Active") - assert_that(contradicted).is_not_equal(active) - panel.queue_free() - - -func test_state_color_stale_differs_from_active() -> void: - ## Stale and Active must have visually distinct colors. - var panel := _make_journal_panel() - if panel == null: return - var stale := panel._state_color("Stale") - var active := panel._state_color("Active") - assert_that(stale).is_not_equal(active) - panel.queue_free() - - -# --------------------------------------------------------------------------- -# UIStrings: knowledge_panel keys (D-042) -# --------------------------------------------------------------------------- - -func test_ui_strings_knowledge_panel_tab_contacts_exists() -> void: - ## Journal title uses knowledge_panel.tab_contacts. - assert_bool(UIStrings.has_key("knowledge_panel.tab_contacts")).override_failure_message( - "UIStrings must have 'knowledge_panel.tab_contacts' key (D-042)" - ).is_true() - - -func test_ui_strings_knowledge_panel_empty_state_exists() -> void: - ## Empty state message uses knowledge_panel.empty_state. - assert_bool(UIStrings.has_key("knowledge_panel.empty_state")).override_failure_message( - "UIStrings must have 'knowledge_panel.empty_state' key (D-042)" - ).is_true() - - -func test_ui_strings_knowledge_panel_empty_state_non_empty() -> void: - if not UIStrings.has_key("knowledge_panel.empty_state"): return - assert_bool(UIStrings.get_text("knowledge_panel.empty_state").length() > 0).is_true() - - -func test_ui_strings_knowledge_panel_tab_contacts_non_empty() -> void: - if not UIStrings.has_key("knowledge_panel.tab_contacts"): return - assert_bool(UIStrings.get_text("knowledge_panel.tab_contacts").length() > 0).is_true() - - -# --------------------------------------------------------------------------- -# D-042: CONFIDENCE_LABELS/SOURCE_LABELS now via UIStrings — FIXED (2026-02-25) -## Previously filed as a gap: journal_panel.gd had hardcoded CONFIDENCE_LABELS dict. -## Fixed by Stig: dicts removed, all labels now use UIStrings.get_text("knowledge_panel.*"). -## Regression guard: verify the dicts are gone and UIStrings fallback works. -# --------------------------------------------------------------------------- - -func test_d042_fixed_panel_has_no_confidence_labels_dict() -> void: - ## Regression: CONFIDENCE_LABELS dict must NOT exist on journal_panel — it was removed. - ## If this test fails, the hardcoded dict was accidentally re-introduced. - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.get("CONFIDENCE_LABELS") == null).override_failure_message( - "D-042 regression: CONFIDENCE_LABELS dict must be removed from journal_panel.gd" - ).is_true() - panel.queue_free() - - -func test_d042_fixed_panel_has_no_source_labels_dict() -> void: - ## Regression: SOURCE_LABELS dict must NOT exist on journal_panel — it was removed. - var panel := _make_journal_panel() - if panel == null: return - assert_bool(panel.get("SOURCE_LABELS") == null).override_failure_message( - "D-042 regression: SOURCE_LABELS dict must be removed from journal_panel.gd" - ).is_true() - panel.queue_free() - - -func test_d042_uistrings_fallback_for_unknown_confidence() -> void: - ## UIStrings falls back to the key string itself for missing keys. - ## journal_panel.gd relies on this for graceful degradation. - var fallback := UIStrings.get_text("knowledge_panel.confidence_nonexistent_level") - assert_that(fallback).override_failure_message( - "UIStrings fallback must return the key string itself for unknown keys" - ).is_equal("knowledge_panel.confidence_nonexistent_level") - - -# --------------------------------------------------------------------------- -# Constants -# --------------------------------------------------------------------------- - -func test_canvas_insert_constant_is_10() -> void: - assert_int(Constants.CANVAS_INSERT).is_equal(10) - - -func test_journal_fade_constants_reasonable() -> void: - ## FADE_IN and FADE_OUT must be short (< 0.5s) for responsive UI. - var panel := _make_journal_panel() - if panel == null: return - assert_float(panel.FADE_IN).is_between(0.0, 0.5) - assert_float(panel.FADE_OUT).is_between(0.0, 0.5) - panel.queue_free() diff --git a/client/tests/test_journal_sprint18.gd.uid b/client/tests/test_journal_sprint18.gd.uid deleted file mode 100644 index f4bb0f116..000000000 --- a/client/tests/test_journal_sprint18.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://cao5jf5h36img diff --git a/client/tests/test_minimap_sprint18.gd b/client/tests/test_minimap_sprint18.gd deleted file mode 100644 index 989219d87..000000000 --- a/client/tests/test_minimap_sprint18.gd +++ /dev/null @@ -1,321 +0,0 @@ -## Sprint 18 — Minimap rendering (#151) -## Spec refs: D-013 (diegetic insert/POI system), D-015 (fixed-north, player-centered), -## D-049 (z-layer 6 = InsertOverlay) -## -## MinimapRenderer: circular insert overlay, always renders frame, draws discovered POIs. -## Scene: res://ui/minimap.tscn (class_name MinimapRenderer) -## Positioned at InsertOverlay/Minimap in main.tscn. -## -## Tests run against live Stig implementation (minimap.gd). -class_name TestMinimapSprint18 -extends GdUnitTestSuite - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -const MINIMAP_SCENE_PATH: String = "res://ui/minimap.tscn" -const MAIN_SCENE = preload("res://scenes/main.tscn") - -func _make_minimap() -> Control: - if not ResourceLoader.exists(MINIMAP_SCENE_PATH): - push_warning("TestMinimapSprint18: minimap.tscn not found — skip") - return null - var node: Control = load(MINIMAP_SCENE_PATH).instantiate() - add_child(node) - return node - - -func _make_poi(overrides: Dictionary = {}) -> Dictionary: - var base: Dictionary = { - "id": "poi_test_001", - "x": 20, - "y": 15, - "poi_category": "location", - "label": "Exit A", - } - base.merge(overrides, true) - return base - - -# --------------------------------------------------------------------------- -# Lifecycle -# --------------------------------------------------------------------------- - -func before_test() -> void: - GameState.discovered_pois = [] - GameState.player_position = Vector2(10.0, 10.0) - GameState.insert_active = true - -func after_test() -> void: - GameState.discovered_pois = [] - GameState.player_position = Vector2.ZERO - GameState.insert_active = true - - -# --------------------------------------------------------------------------- -# Scene and class -# --------------------------------------------------------------------------- - -func test_minimap_scene_exists() -> void: - assert_bool(ResourceLoader.exists(MINIMAP_SCENE_PATH)).override_failure_message( - "Minimap scene must exist at res://ui/minimap.tscn (#151)" - ).is_true() - - -func test_minimap_instantiates_without_crash() -> void: - var mm := _make_minimap() - if mm == null: return - assert_that(mm).is_not_null() - mm.queue_free() - - -func test_minimap_is_minimap_renderer_class() -> void: - ## class_name MinimapRenderer in minimap.gd. - var mm := _make_minimap() - if mm == null: return - assert_bool(mm is MinimapRenderer).override_failure_message( - "Minimap node must be a MinimapRenderer instance (check class_name in minimap.gd)" - ).is_true() - mm.queue_free() - - -# --------------------------------------------------------------------------- -# Constants: D-015, visual parameters -# --------------------------------------------------------------------------- - -func test_minimap_radius_constant() -> void: - ## MINIMAP_RADIUS defines the sim-tile distance of visible POI area. - ## Value is tuned to 24 tiles — reasonable coverage without map reveal. - assert_float(MinimapRenderer.MINIMAP_RADIUS).override_failure_message( - "MinimapRenderer.MINIMAP_RADIUS must be 24.0" - ).is_equal_approx(24.0, 0.01) - - -func test_player_dot_radius_defined() -> void: - ## Player dot must be visible (> 0) and distinct from POI dot. - assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater(0.0) - - -func test_poi_dot_radius_defined() -> void: - ## POI dot must be visible (> 0). - assert_float(MinimapRenderer.POI_DOT_RADIUS).is_greater(0.0) - - -func test_player_dot_larger_than_poi_dot() -> void: - ## D-015: Player is always centered and visually distinct. - ## Player dot should be at least as large as POI dot. - assert_float(MinimapRenderer.PLAYER_DOT_RADIUS).is_greater_equal(MinimapRenderer.POI_DOT_RADIUS) - - -# --------------------------------------------------------------------------- -# _category_color() — D-013 POI category color mapping -# --------------------------------------------------------------------------- - -func test_category_color_danger_is_hostile_color() -> void: - ## "danger", "threat", "hostile" → ENTITY_COLOR_HOSTILE (red) - for cat in ["danger", "threat", "hostile"]: - var color: Color = MinimapRenderer._category_color(cat) - assert_that(color).override_failure_message( - "Category '%s' must map to ENTITY_COLOR_HOSTILE" % cat - ).is_equal(Constants.ENTITY_COLOR_HOSTILE) - - -func test_category_color_evidence_is_poi_color() -> void: - ## "evidence", "note", "clue" → ENTITY_COLOR_POI (amber) - for cat in ["evidence", "note", "clue"]: - var color: Color = MinimapRenderer._category_color(cat) - assert_that(color).override_failure_message( - "Category '%s' must map to ENTITY_COLOR_POI (amber)" % cat - ).is_equal(Constants.ENTITY_COLOR_POI) - - -func test_category_color_contact_is_unknown_color() -> void: - ## "contact", "npc", "person" → ENTITY_COLOR_UNKNOWN (teal) - for cat in ["contact", "npc", "person"]: - var color: Color = MinimapRenderer._category_color(cat) - assert_that(color).override_failure_message( - "Category '%s' must map to ENTITY_COLOR_UNKNOWN (teal)" % cat - ).is_equal(Constants.ENTITY_COLOR_UNKNOWN) - - -func test_category_color_unknown_category_defaults_to_insert_text() -> void: - ## Unknown/unspecified categories → INSERT_COLOR_TEXT (white-blue default) - var color: Color = MinimapRenderer._category_color("some_unknown_type") - assert_that(color).override_failure_message( - "Unknown category must default to INSERT_COLOR_TEXT" - ).is_equal(Constants.INSERT_COLOR_TEXT) - - -func test_category_color_empty_string_defaults() -> void: - ## Empty category string → default color, no crash. - var color: Color = MinimapRenderer._category_color("") - assert_that(color).is_equal(Constants.INSERT_COLOR_TEXT) - - -func test_category_color_case_insensitive() -> void: - ## Category matching is case-insensitive (uses to_lower()). - var danger_lower := MinimapRenderer._category_color("danger") - var danger_upper := MinimapRenderer._category_color("DANGER") - var danger_mixed := MinimapRenderer._category_color("Danger") - assert_that(danger_lower).is_equal(danger_upper) - assert_that(danger_lower).is_equal(danger_mixed) - - -# --------------------------------------------------------------------------- -# set_insert_active() — D-049: insert layer visibility -# --------------------------------------------------------------------------- - -func test_set_insert_active_false_hides_minimap() -> void: - ## When insert is inactive, minimap must be hidden. - var mm := _make_minimap() - if mm == null: return - mm.set_insert_active(false) - assert_bool(mm.visible).override_failure_message( - "set_insert_active(false) must hide the minimap" - ).is_false() - mm.queue_free() - - -func test_set_insert_active_true_shows_minimap() -> void: - ## When insert is active, minimap must be visible. - var mm := _make_minimap() - if mm == null: return - mm.set_insert_active(false) - mm.set_insert_active(true) - assert_bool(mm.visible).override_failure_message( - "set_insert_active(true) must show the minimap" - ).is_true() - mm.queue_free() - - -# --------------------------------------------------------------------------- -# Main scene structural check: InsertOverlay/Minimap -# --------------------------------------------------------------------------- - -func test_minimap_in_main_scene_on_insert_overlay() -> void: - ## D-049: Minimap must be in InsertOverlay (CanvasLayer 10), not UILayer. - ## Scene path: Game/InsertOverlay/Minimap or InsertOverlay/Minimap. - if not ResourceLoader.exists("res://scenes/main.tscn"): - push_warning("TestMinimapSprint18: main.tscn not found — scene tree test skipped") - return - var scene: Node = MAIN_SCENE.instantiate() - auto_free(scene) - add_child(scene) - - # Check for Minimap in InsertOverlay - var insert_overlay := scene.get_node_or_null("InsertOverlay") - assert_that(insert_overlay != null).override_failure_message( - "InsertOverlay (CanvasLayer 10) must exist in main.tscn" - ).is_true() - if insert_overlay == null: return - - var minimap := insert_overlay.get_node_or_null("Minimap") - assert_that(minimap != null).override_failure_message( - "Minimap must be a child of InsertOverlay in main.tscn (D-049: insert layer)" - ).is_true() - if minimap == null: return - - assert_bool(minimap is MinimapRenderer).override_failure_message( - "InsertOverlay/Minimap must be a MinimapRenderer instance" - ).is_true() - - -func test_insert_overlay_is_canvas_layer_10() -> void: - ## InsertOverlay must be CanvasLayer 10 (CANVAS_INSERT per D-049). - if not ResourceLoader.exists("res://scenes/main.tscn"): - push_warning("TestMinimapSprint18: main.tscn not found — canvas layer test skipped") - return - var scene: Node = MAIN_SCENE.instantiate() - auto_free(scene) - add_child(scene) - - var insert_overlay := scene.get_node_or_null("InsertOverlay") as CanvasLayer - if insert_overlay == null: return - assert_int(insert_overlay.layer).override_failure_message( - "InsertOverlay must be CanvasLayer %d (CANVAS_INSERT)" % Constants.CANVAS_INSERT - ).is_equal(Constants.CANVAS_INSERT) - - -# --------------------------------------------------------------------------- -# GameState.discovered_pois integration -# --------------------------------------------------------------------------- - -func test_discovered_pois_field_exists_in_gamestate() -> void: - assert_bool(GameState.has("discovered_pois")).override_failure_message( - "GameState must have 'discovered_pois' field (#151)" - ).is_true() - - -func test_discovered_pois_set_from_poi_list_snapshot() -> void: - ## Snapshot with "poi_list" key (Sprint 17 server wire name) populates discovered_pois. - GameState.apply_snapshot({ - "tick": 1, - "poi_list": [ - _make_poi({"id": "p1", "x": 50, "y": 30, "poi_category": "location"}), - _make_poi({"id": "p2", "x": 80, "y": 15, "poi_category": "contact"}), - ], - }) - assert_int(GameState.discovered_pois.size()).override_failure_message( - "discovered_pois must be populated from snapshot 'poi_list' field" - ).is_equal(2) - - -func test_discovered_pois_set_from_discovered_pois_snapshot() -> void: - ## Snapshot with "discovered_pois" key also works. - GameState.apply_snapshot({ - "tick": 2, - "discovered_pois": [_make_poi()], - }) - assert_int(GameState.discovered_pois.size()).is_equal(1) - - -func test_discovered_pois_persists_when_absent_from_snapshot() -> void: - ## Like player_knowledge: POI list persists when server doesn't send an update. - GameState.discovered_pois = [_make_poi()] - GameState.apply_snapshot({"tick": 3}) - assert_int(GameState.discovered_pois.size()).override_failure_message( - "discovered_pois must persist when absent from snapshot (not cleared each tick)" - ).is_equal(1) - - -func test_discovered_pois_poi_category_field_present() -> void: - ## MinimapRenderer reads poi_category to determine shape/color. - ## Verify the wire format includes this field. - GameState.apply_snapshot({ - "tick": 1, - "poi_list": [_make_poi({"poi_category": "danger"})], - }) - assert_int(GameState.discovered_pois.size()).is_greater(0) - var first_poi: Dictionary = GameState.discovered_pois[0] - assert_bool(first_poi.has("poi_category")).override_failure_message( - "POI entries must have 'poi_category' field for MinimapRenderer shape selection" - ).is_true() - - -func test_discovered_pois_x_y_fields_present() -> void: - ## MinimapRenderer reads x, y for position calculation. - GameState.apply_snapshot({ - "tick": 1, - "poi_list": [_make_poi({"x": 42, "y": 17})], - }) - assert_int(GameState.discovered_pois.size()).is_greater(0) - var first_poi: Dictionary = GameState.discovered_pois[0] - assert_bool(first_poi.has("x") and first_poi.has("y")).override_failure_message( - "POI entries must have 'x' and 'y' coordinate fields" - ).is_true() - - -# --------------------------------------------------------------------------- -# Color constants: all distinct -# --------------------------------------------------------------------------- - -func test_category_colors_are_distinct() -> void: - ## All three primary category color groups must be visually distinct. - var danger_color := MinimapRenderer._category_color("danger") - var evidence_color := MinimapRenderer._category_color("evidence") - var contact_color := MinimapRenderer._category_color("contact") - assert_that(danger_color).is_not_equal(evidence_color) - assert_that(evidence_color).is_not_equal(contact_color) - assert_that(danger_color).is_not_equal(contact_color) diff --git a/client/tests/test_minimap_sprint18.gd.uid b/client/tests/test_minimap_sprint18.gd.uid deleted file mode 100644 index 3e8b699a8..000000000 --- a/client/tests/test_minimap_sprint18.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://dm46ip672i3jc diff --git a/client/tests/test_session_manager_sprint19.gd b/client/tests/test_session_manager_sprint19.gd deleted file mode 100644 index 9174bb10f..000000000 --- a/client/tests/test_session_manager_sprint19.gd +++ /dev/null @@ -1,210 +0,0 @@ -## Sprint 19 — Game session management (#258, D-085) -## Per-game save directories: created on New Game, resumed via game-id. -## SessionManager autoload: new_game(), resume_game(), list_game_dirs(). -class_name TestSessionManagerSprint19 -extends GdUnitTestSuite - -const MAIN_MENU_SCENE = preload("res://scenes/main_menu.tscn") - -# Game IDs created during the current test — deleted in after_test(). -var _created_ids: Array = [] - - -# --------------------------------------------------------------------------- -# Lifecycle -# --------------------------------------------------------------------------- - -func before_test() -> void: - GameState.current_game_id = "" - _created_ids = [] - - -func after_test() -> void: - for game_id in _created_ids: - var path := "user://saves/" + game_id - DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) - _created_ids.clear() - GameState.current_game_id = "" - - -# --------------------------------------------------------------------------- -# Helper: call new_game() and track the created directory for cleanup. -# --------------------------------------------------------------------------- - -func _new_game() -> String: - var game_id := SessionManager.new_game() - if not game_id.is_empty(): - _created_ids.append(game_id) - return game_id - - -# --------------------------------------------------------------------------- -# GameState.current_game_id field -# --------------------------------------------------------------------------- - -func test_current_game_id_field_exists() -> void: - ## D-085: GameState must have current_game_id field. - assert_bool(GameState.has("current_game_id")).override_failure_message( - "GameState must have 'current_game_id' field (D-085 #258)" - ).is_true() - - -func test_current_game_id_default_is_empty_string() -> void: - ## Before any session starts, current_game_id is empty. - GameState.current_game_id = "" - assert_str(GameState.current_game_id).override_failure_message( - "GameState.current_game_id default must be empty string" - ).is_empty() - - -# --------------------------------------------------------------------------- -# SessionManager autoload exists -# --------------------------------------------------------------------------- - -func test_session_manager_autoload_exists() -> void: - ## SessionManager must be registered as an autoload. - var sm := Engine.get_singleton("SessionManager") - assert_that(sm != null).override_failure_message( - "SessionManager must be registered as autoload in project.godot (#258)" - ).is_true() - - -# --------------------------------------------------------------------------- -# new_game() — game-id format and GameState update -# --------------------------------------------------------------------------- - -func test_new_game_returns_non_empty_string() -> void: - var game_id := _new_game() - assert_str(game_id).override_failure_message( - "SessionManager.new_game() must return a non-empty game-id string" - ).is_not_empty() - - -func test_new_game_sets_current_game_id_on_gamestate() -> void: - var game_id := _new_game() - assert_str(GameState.current_game_id).override_failure_message( - "new_game() must set GameState.current_game_id" - ).is_equal(game_id) - - -func test_new_game_id_format_has_two_dashes() -> void: - ## Format: -- — two separator dashes. - var game_id := _new_game() - var parts := game_id.split("-") - assert_int(parts.size()).override_failure_message( - "game-id must have format -- (3 parts separated by '-')" - ).is_equal(3) - - -func test_new_game_id_first_part_is_8_digits() -> void: - ## First part is YYYYMMDD — 8 decimal digits. - var game_id := _new_game() - var parts := game_id.split("-") - assert_int(parts[0].length()).override_failure_message( - "game-id first part (date) must be 8 characters (YYYYMMDD)" - ).is_equal(8) - - -func test_new_game_id_second_part_is_6_digits() -> void: - ## Second part is HHMMSS — 6 decimal digits. - var game_id := _new_game() - var parts := game_id.split("-") - assert_int(parts[1].length()).override_failure_message( - "game-id second part (time) must be 6 characters (HHMMSS)" - ).is_equal(6) - - -func test_new_game_id_third_part_is_6_hex_chars() -> void: - ## Third part is 6 hex characters (RNG seed). - var game_id := _new_game() - var parts := game_id.split("-") - assert_int(parts[2].length()).override_failure_message( - "game-id third part (hex seed) must be 6 characters" - ).is_equal(6) - - -func test_new_game_ids_are_unique() -> void: - ## Two rapid new_game() calls should produce different IDs - ## (different RNG seeds; same-second timestamps are valid but seeds differ). - var id1 := _new_game() - var id2 := _new_game() - # Check that hex seeds differ (they almost certainly will) - var seed1 := id1.split("-")[2] - var seed2 := id2.split("-")[2] - assert_str(seed1).override_failure_message( - "Successive new_game() calls should have different RNG seeds" - ).is_not_equal(seed2) - - -# --------------------------------------------------------------------------- -# resume_game() — sets GameState.current_game_id -# --------------------------------------------------------------------------- - -func test_resume_game_sets_current_game_id() -> void: - var test_id := "20260225-143022-a7b3f1" - SessionManager.resume_game(test_id) - assert_str(GameState.current_game_id).override_failure_message( - "resume_game() must set GameState.current_game_id to the given id" - ).is_equal(test_id) - - -func test_resume_game_overwrites_previous_game_id() -> void: - SessionManager.resume_game("20260225-100000-aabbcc") - SessionManager.resume_game("20260225-120000-112233") - assert_str(GameState.current_game_id).is_equal("20260225-120000-112233") - - -# --------------------------------------------------------------------------- -# Main menu scene -# --------------------------------------------------------------------------- - -func test_main_menu_scene_exists() -> void: - assert_bool(ResourceLoader.exists("res://scenes/main_menu.tscn")).override_failure_message( - "Main menu scene must exist at res://scenes/main_menu.tscn (#258)" - ).is_true() - - -func test_main_menu_instantiates_without_crash() -> void: - if not ResourceLoader.exists("res://scenes/main_menu.tscn"): - push_warning("TestSessionManagerSprint19: main_menu.tscn not found — skip") - return - var scene: Node = MAIN_MENU_SCENE.instantiate() - auto_free(scene) - add_child(scene) - assert_that(scene).is_not_null() - - -func test_main_menu_has_new_game_button() -> void: - if not ResourceLoader.exists("res://scenes/main_menu.tscn"): - return - var scene: Node = MAIN_MENU_SCENE.instantiate() - auto_free(scene) - add_child(scene) - var btn := scene.get_node_or_null("VBox/NewGameBtn") - assert_that(btn != null).override_failure_message( - "Main menu must have VBox/NewGameBtn (#258)" - ).is_true() - - -func test_main_menu_has_continue_button() -> void: - if not ResourceLoader.exists("res://scenes/main_menu.tscn"): - return - var scene: Node = MAIN_MENU_SCENE.instantiate() - auto_free(scene) - add_child(scene) - var btn := scene.get_node_or_null("VBox/ContinueBtn") - assert_that(btn != null).override_failure_message( - "Main menu must have VBox/ContinueBtn (#258)" - ).is_true() - - -# --------------------------------------------------------------------------- -# Project main scene changed to main_menu.tscn -# --------------------------------------------------------------------------- - -func test_project_main_scene_is_main_menu() -> void: - ## D-085: project boots to main menu, not directly to game scene. - var scene_path: String = ProjectSettings.get_setting("application/run/main_scene", "") - assert_str(scene_path).override_failure_message( - "project.godot run/main_scene must be res://scenes/main_menu.tscn (#258)" - ).is_equal("res://scenes/main_menu.tscn") diff --git a/client/tests/test_session_manager_sprint19.gd.uid b/client/tests/test_session_manager_sprint19.gd.uid deleted file mode 100644 index 18eb168e1..000000000 --- a/client/tests/test_session_manager_sprint19.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://c7lnnr2apbyqw diff --git a/client/tests/test_sprint30.gd b/client/tests/test_sprint30.gd deleted file mode 100644 index dff631bae..000000000 --- a/client/tests/test_sprint30.gd +++ /dev/null @@ -1,552 +0,0 @@ -## Sprint 30 — QA acceptance tests -## -## Covers all 5 Sprint 30 client tickets: -## #718 — Persist CharacterVisualDescriptor on new game start -## #719 — Hair highlight: make swatch read-only (Option B — no compositor yet) -## #720 — Replace DirAccess scanning with manifest JSON for export builds -## #712 — BoneAttachment3D marker above Head bone for floating icons -## #674 — Star map insert module (test-first: scene must exist when implemented) -## -## Test convention: -## - Tests that should PASS immediately = regression guards on existing code -## - Tests prefixed [ACCEPTANCE] = will FAIL until the ticket is implemented -## -## Ticket refs: #718, #719, #720, #712, #674 -class_name TestSprint30 -extends GdUnitTestSuite - - -func before_each() -> void: - # Reset GameState fields touched by #718 tests to avoid cross-test pollution. - GameState.character_visual_descriptor = null - - -func after_each() -> void: - GameState.character_visual_descriptor = null - - -# ============================================================================= -# #718 — Persist CharacterVisualDescriptor -# ============================================================================= - -func test_game_state_has_character_visual_descriptor_field() -> void: - ## GameState.character_visual_descriptor must exist and default to null. - ## Confirms the field added in game_state.gd line 103 is present. - var gs := GameState.new() - auto_free(gs) - # The field is declared on the class — access it without error - var val: Variant = gs.get("character_visual_descriptor") - # Field should exist (not return null from missing property vs. null value) - assert_bool(gs.has_method("apply_snapshot")).override_failure_message( - "GameState must be a valid autoload class with apply_snapshot" - ).is_true() - # The property itself must be gettable and null by default - assert_bool(val == null).override_failure_message( - "GameState.character_visual_descriptor must default to null" - ).is_true() - - -func test_descriptor_to_dict_includes_all_required_fields() -> void: - ## CharacterVisualDescriptor.to_dict() must include all wire-format fields. - var desc := CharacterVisualDescriptor.new() - auto_free(desc) - var d := desc.to_dict() - var required_keys := [ - "body_type", "head_id", "hair_id", "hair_tint", - "facial_hair_id", "facial_hair_tint", "eyebrow_id", "eyebrow_tint", - "eye_color", "skin_tone", "clothing_slots", "clothing_tints", - "accessory_slots", "accessory_tints", - ] - for key in required_keys: - assert_bool(d.has(key)).override_failure_message( - "to_dict() must include field '%s'" % key - ).is_true() - - -func test_descriptor_to_dict_body_type_is_wire_string() -> void: - ## body_type in to_dict() must be a string (rmp_serde unit enum), not an int. - var desc := CharacterVisualDescriptor.new() - auto_free(desc) - desc.body_type = CharacterVisualDescriptor.BodyType.AVERAGE_M - var d := desc.to_dict() - assert_str(d["body_type"]).override_failure_message( - "to_dict() body_type must be wire string 'AverageM'" - ).is_equal("AverageM") - - -func test_descriptor_round_trip_preserves_fields() -> void: - ## from_dict(to_dict(desc)) must preserve all scalar fields. - var original := CharacterVisualDescriptor.new() - auto_free(original) - original.body_type = CharacterVisualDescriptor.BodyType.THIN_F - original.head_id = "head_002" - original.hair_id = "bob" - original.hair_tint = Color(0.8, 0.4, 0.2) - original.skin_tone = 3 - - var wire := original.to_dict() - var restored := CharacterVisualDescriptor.from_dict(wire) - assert_bool(restored != null).override_failure_message( - "from_dict() must return a descriptor for valid wire data" - ).is_true() - if restored == null: - return - - assert_int(int(restored.body_type)).override_failure_message( - "body_type must survive round-trip" - ).is_equal(int(CharacterVisualDescriptor.BodyType.THIN_F)) - - assert_str(restored.head_id).override_failure_message( - "head_id must survive round-trip" - ).is_equal("head_002") - - assert_str(restored.hair_id).override_failure_message( - "hair_id must survive round-trip" - ).is_equal("bob") - - assert_int(restored.skin_tone).override_failure_message( - "skin_tone must survive round-trip" - ).is_equal(3) - - -func test_descriptor_from_dict_returns_null_when_missing_body_type() -> void: - ## from_dict() must return null if body_type is absent (required field). - var d := {"head_id": "head_001"} # missing body_type - var result := CharacterVisualDescriptor.from_dict(d) - assert_bool(result == null).override_failure_message( - "from_dict() must return null when body_type is missing" - ).is_true() - - -func test_descriptor_color_encoding_is_float_array() -> void: - ## Colors must encode as [r, g, b, a] float arrays for rmp_serde compatibility. - var desc := CharacterVisualDescriptor.new() - auto_free(desc) - desc.eye_color = Color(0.1, 0.2, 0.3, 1.0) - var d := desc.to_dict() - var encoded: Variant = d["eye_color"] - assert_bool(encoded is Array).override_failure_message( - "eye_color must encode as an Array [r, g, b, a]" - ).is_true() - if not (encoded is Array): - return - assert_int((encoded as Array).size()).override_failure_message( - "eye_color array must have 4 elements" - ).is_equal(4) - assert_float((encoded as Array)[0]).override_failure_message( - "eye_color[0] (r) must be approx 0.1" - ).is_equal_approx(0.1, 0.001) - - -func test_apply_snapshot_restores_character_visual_descriptor() -> void: - ## #718: apply_snapshot() must restore character_visual_descriptor from - ## the "character_visual_descriptor" key in ObserverSnapshot (save/load path). - var desc := CharacterVisualDescriptor.new() - desc.body_type = CharacterVisualDescriptor.BodyType.MUSCULAR_F - desc.head_id = "head_003" - desc.hair_id = "dreads" - desc.skin_tone = 5 - var snapshot := { - "character_visual_descriptor": desc.to_dict(), - } - GameState.apply_snapshot(snapshot) - var restored: Variant = GameState.character_visual_descriptor - assert_bool(restored != null).override_failure_message( - "apply_snapshot() must restore character_visual_descriptor from snapshot" - ).is_true() - if restored != null and restored is CharacterVisualDescriptor: - var r := restored as CharacterVisualDescriptor - assert_int(int(r.body_type)).override_failure_message( - "restored body_type must match" - ).is_equal(int(CharacterVisualDescriptor.BodyType.MUSCULAR_F)) - assert_str(r.head_id).override_failure_message( - "restored head_id must match" - ).is_equal("head_003") - assert_int(r.skin_tone).override_failure_message( - "restored skin_tone must match" - ).is_equal(5) - - -func test_apply_snapshot_preserves_descriptor_when_field_absent() -> void: - ## #718: If snapshot lacks "character_visual_descriptor", the existing field - ## must not be overwritten (server only sends when descriptor changes). - var desc := CharacterVisualDescriptor.new() - desc.hair_id = "bob" - GameState.character_visual_descriptor = desc - # Snapshot with no character_visual_descriptor key - GameState.apply_snapshot({"tick": 1}) - var after: Variant = GameState.character_visual_descriptor - assert_bool(after != null).override_failure_message( - "apply_snapshot() must NOT clear descriptor when field is absent" - ).is_true() - if after is CharacterVisualDescriptor: - assert_str((after as CharacterVisualDescriptor).hair_id).override_failure_message( - "descriptor must be unchanged after snapshot with no character_visual_descriptor key" - ).is_equal("bob") - - -func test_protocol_encode_startup_includes_descriptor() -> void: - ## #718: Protocol.encode_startup_message() must include "character_visual_descriptor" - ## in the encoded payload when a descriptor is provided. - var desc := CharacterVisualDescriptor.new() - desc.body_type = CharacterVisualDescriptor.BodyType.THIN_M - desc.hair_id = "buzzed" - var bytes := Protocol.encode_startup_message(12345, "detective", desc) - assert_bool(bytes.size() > 0).override_failure_message( - "encode_startup_message() must produce non-empty bytes" - ).is_true() - # Decode and verify the field is present (Messagepack.decode returns {status, value}) - var raw = Messagepack.decode(bytes) - assert_bool(raw.status == null).override_failure_message( - "encode_startup_message() output must be valid msgpack" - ).is_true() - if raw.status != null: - return - var msg: Dictionary = raw.value as Dictionary - assert_bool(msg.has("character_visual_descriptor")).override_failure_message( - "StartupMessage must include 'character_visual_descriptor' key when descriptor is provided" - ).is_true() - if msg.has("character_visual_descriptor"): - assert_bool(msg["character_visual_descriptor"] is Dictionary).override_failure_message( - "character_visual_descriptor in StartupMessage must be a Dictionary" - ).is_true() - - -func test_protocol_encode_startup_omits_descriptor_when_null() -> void: - ## #718: encode_startup_message() must still produce valid bytes when descriptor is null. - var bytes := Protocol.encode_startup_message(0, "detective", null) - assert_bool(bytes.size() > 0).override_failure_message( - "encode_startup_message() must produce valid bytes even with null descriptor" - ).is_true() - - -func test_descriptor_has_no_hair_highlight_tint_field() -> void: - ## CharacterVisualDescriptor must NOT have a hair_highlight_tint field. - ## The highlight is always auto-derived from hair_tint (Option B of #719). - ## to_dict() must not include it in the wire format. - var desc := CharacterVisualDescriptor.new() - auto_free(desc) - var d := desc.to_dict() - assert_bool(d.has("hair_highlight_tint")).override_failure_message( - "to_dict() must NOT include hair_highlight_tint — highlight is auto-derived" - ).is_false() - assert_bool("hair_highlight_tint" in desc).override_failure_message( - "CharacterVisualDescriptor must not define a hair_highlight_tint property" - ).is_false() - - -# ============================================================================= -# #719 — Hair highlight swatch (Option B: read-only, auto-derived) -# ============================================================================= - -func test_derive_hair_highlight_lightens_primary() -> void: - ## _derive_hair_highlight() must return primary.lightened(0.3). - ## Tests the derivation formula in character_creation.gd:1917. - var cc_scene_path := "res://scenes/character_creation.tscn" - if not ResourceLoader.exists(cc_scene_path): - push_warning("test_derive_hair_highlight_lightens_primary: scene not available in headless — skipping") - return - var packed := load(cc_scene_path) as PackedScene - if packed == null: - return - var cc := packed.instantiate() as CharacterCreation - if cc == null: - push_warning("test_derive_hair_highlight_lightens_primary: failed to instantiate — skipping") - return - auto_free(cc) - - # CharacterCreation._derive_hair_highlight is a private method but testable via call() - var primary := Color(0.4, 0.3, 0.5) - var expected := primary.lightened(0.3) - var result: Variant = cc.call("_derive_hair_highlight", primary) - assert_bool(result is Color).override_failure_message( - "_derive_hair_highlight must return a Color" - ).is_true() - if not (result is Color): - return - var r := result as Color - assert_float(r.r).override_failure_message("derived highlight.r incorrect").is_equal_approx(expected.r, 0.001) - assert_float(r.g).override_failure_message("derived highlight.g incorrect").is_equal_approx(expected.g, 0.001) - assert_float(r.b).override_failure_message("derived highlight.b incorrect").is_equal_approx(expected.b, 0.001) - - -func test_hair_highlight_swatch_exists_in_ui() -> void: - ## [ACCEPTANCE #719] After fix, _hair_highlight_swatch must be non-null - ## (a display node must be created in _build_hair_color_dock). - ## WILL FAIL until #719 is implemented. - var cc_scene_path := "res://scenes/character_creation.tscn" - if not ResourceLoader.exists(cc_scene_path): - push_warning("test_hair_highlight_swatch_exists_in_ui: scene not available in headless — skipping") - return - var packed := load(cc_scene_path) as PackedScene - if packed == null: - return - var cc := packed.instantiate() as CharacterCreation - if cc == null: - return - auto_free(cc) - add_child(cc) - await get_tree().process_frame - - # _hair_highlight_swatch must be set after _ready() builds the hair color dock - var swatch: Variant = cc.get("_hair_highlight_swatch") - assert_bool(swatch != null).override_failure_message( - "[#719] _hair_highlight_swatch must not be null — a display node must be created" - ).is_true() - - -func test_hair_highlight_swatch_is_not_interactive() -> void: - ## [ACCEPTANCE #719] The highlight swatch must be non-interactive. - ## Either mouse_filter = IGNORE, or the node is a ColorRect (not a Button with a callback). - ## WILL FAIL until #719 is implemented. - var cc_scene_path := "res://scenes/character_creation.tscn" - if not ResourceLoader.exists(cc_scene_path): - push_warning("test_hair_highlight_swatch_is_not_interactive: scene not available — skipping") - return - var packed := load(cc_scene_path) as PackedScene - if packed == null: - return - var cc := packed.instantiate() as CharacterCreation - if cc == null: - return - auto_free(cc) - add_child(cc) - await get_tree().process_frame - - var swatch: Variant = cc.get("_hair_highlight_swatch") - if swatch == null: - push_warning("test_hair_highlight_swatch_is_not_interactive: swatch not found — #719 not yet implemented") - return - - # If swatch is a Control node, mouse_filter must be IGNORE (2) - if swatch is Control: - var ctrl := swatch as Control - assert_int(ctrl.mouse_filter).override_failure_message( - "[#719] hair highlight swatch must have mouse_filter=IGNORE (non-interactive)" - ).is_equal(Control.MOUSE_FILTER_IGNORE) - - -# ============================================================================= -# #720 — Manifest JSON completeness (replaces DirAccess scanning) -# ============================================================================= - -func test_manifest_json_is_parseable() -> void: - ## manifest.json must exist and parse as a Dictionary. - var path := "res://assets/characters/manifest.json" - assert_bool(ResourceLoader.exists(path) or FileAccess.file_exists(path)).override_failure_message( - "manifest.json must exist at res://assets/characters/manifest.json" - ).is_true() - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - push_warning("test_manifest_json_is_parseable: file not openable — skipping") - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - assert_bool(parsed is Dictionary).override_failure_message( - "manifest.json must parse as a JSON object (Dictionary)" - ).is_true() - - -func test_manifest_hair_includes_all_asset_dirs() -> void: - ## [ACCEPTANCE #720] manifest.json "hair" array must include every .glb in - ## assets/characters/hair/. Currently missing: balding, buzzed_female, dreads, - ## long_dreads, mohawk, ponytail_f, simple_parted, slick_back. - ## WILL FAIL until #720 populates the manifest fully. - var path := "res://assets/characters/manifest.json" - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - push_warning("test_manifest_hair_includes_all_asset_dirs: manifest not readable — skipping") - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return - var manifest := parsed as Dictionary - var hair_list: Array = manifest.get("hair", []) - - # All hair IDs confirmed from assets/characters/hair/*.glb scan (2026-04-04) - var expected_hair := [ - "bald", "balding", "bob", "buns", "buzzed", "buzzed_female", - "dreads", "long", "long_dreads", "mohawk", "ponytail", "ponytail_f", - "simple_parted", "slick_back", - ] - for hair_id in expected_hair: - assert_bool(hair_list.has(hair_id)).override_failure_message( - "[#720] manifest 'hair' must include '%s'" % hair_id - ).is_true() - - -func test_manifest_heads_are_populated() -> void: - ## [ACCEPTANCE #720] manifest.json "heads" must not be empty. - ## heads/templates/ contains head_001..head_004 — all must be listed. - ## WILL FAIL until #720 populates the manifest. - var path := "res://assets/characters/manifest.json" - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - push_warning("test_manifest_heads_are_populated: manifest not readable — skipping") - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return - var manifest := parsed as Dictionary - var heads_list: Array = manifest.get("heads", []) - - var expected_heads := ["head_001", "head_002", "head_003", "head_004"] - assert_bool(not heads_list.is_empty()).override_failure_message( - "[#720] manifest 'heads' must not be empty — 4 head templates exist" - ).is_true() - for head_id in expected_heads: - assert_bool(heads_list.has(head_id)).override_failure_message( - "[#720] manifest 'heads' must include '%s'" % head_id - ).is_true() - - -func test_manifest_body_types_includes_all_11() -> void: - ## [ACCEPTANCE #720] manifest.json "body_types" must include all 11 types. - ## Currently has 6; missing: thin_m, thin_f, heavy_m, heavy_f, child. - ## WILL FAIL until #720 updates the manifest. - var path := "res://assets/characters/manifest.json" - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - push_warning("test_manifest_body_types_includes_all_11: manifest not readable — skipping") - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return - var manifest := parsed as Dictionary - var bt_list: Array = manifest.get("body_types", []) - - var expected_types := [ - "thin_m", "thin_f", "average_m", "average_f", - "muscular_m", "muscular_f", "teen_m", "teen_f", - "heavy_m", "heavy_f", "child", - ] - for bt in expected_types: - assert_bool(bt_list.has(bt)).override_failure_message( - "[#720] manifest 'body_types' must include '%s'" % bt - ).is_true() - - -func test_manifest_clothing_includes_all_items() -> void: - ## [ACCEPTANCE #720] manifest.json "clothing" must include all items in - ## assets/characters/clothing/. Currently missing: boots_work, coveralls_basic, - ## jacket_utility, pants_cargo, shirt_henley. - ## WILL FAIL until #720 populates the manifest. - var path := "res://assets/characters/manifest.json" - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - push_warning("test_manifest_clothing_includes_all_items: manifest not readable — skipping") - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return - var manifest := parsed as Dictionary - var clothing_data: Variant = manifest.get("clothing", {}) - var clothing_keys: Array = [] - if clothing_data is Dictionary: - clothing_keys = (clothing_data as Dictionary).keys() - - # All clothing item IDs confirmed from assets/characters/clothing/ scan (2026-04-04) - var expected_items := [ - "boots_work", "coveralls_basic", "jacket_utility", "pants_cargo", - "peasant_pants", "peasant_shoes", "peasant_tunic", "shirt_henley", - ] - for item_id in expected_items: - assert_bool(clothing_keys.has(item_id)).override_failure_message( - "[#720] manifest 'clothing' must include '%s'" % item_id - ).is_true() - - -func test_manifest_eyebrows_are_populated() -> void: - ## [ACCEPTANCE #720] manifest.json "eyebrows" must list all eyebrow styles. - ## assets/characters/eyebrows/ has: female, regular, teen, thick. - ## WILL FAIL until #720 populates the manifest. - var path := "res://assets/characters/manifest.json" - var file := FileAccess.open(path, FileAccess.READ) - if file == null: - push_warning("test_manifest_eyebrows_are_populated: manifest not readable — skipping") - return - var parsed: Variant = JSON.parse_string(file.get_as_text()) - file.close() - if not (parsed is Dictionary): - return - var manifest := parsed as Dictionary - var eb_list: Array = manifest.get("eyebrows", []) - - var expected_eyebrows := ["female", "regular", "teen", "thick"] - assert_bool(not eb_list.is_empty()).override_failure_message( - "[#720] manifest 'eyebrows' must not be empty" - ).is_true() - for eb_id in expected_eyebrows: - assert_bool(eb_list.has(eb_id)).override_failure_message( - "[#720] manifest 'eyebrows' must include '%s'" % eb_id - ).is_true() - - -func test_dir_access_scan_functions_removed() -> void: - ## [ACCEPTANCE #720] After fix, _scan_subdirs and _scan_asset_ids must be - ## removed from CharacterCreation. These functions fail in exported PCK builds. - ## WILL FAIL until #720 removes the DirAccess fallbacks. - var cc_scene_path := "res://scenes/character_creation.tscn" - if not ResourceLoader.exists(cc_scene_path): - push_warning("test_dir_access_scan_functions_removed: scene not available — skipping") - return - var packed := load(cc_scene_path) as PackedScene - if packed == null: - return - var cc := packed.instantiate() as CharacterCreation - if cc == null: - return - auto_free(cc) - - assert_bool(cc.has_method("_scan_subdirs")).override_failure_message( - "[#720] _scan_subdirs must be removed — use manifest JSON instead" - ).is_false() - assert_bool(cc.has_method("_scan_asset_ids")).override_failure_message( - "[#720] _scan_asset_ids must be removed — use manifest JSON instead" - ).is_false() - - -# ============================================================================= -# #712 — BoneAttachment3D overhead anchor in CharacterVisual -# ============================================================================= - -func test_character_visual_has_get_overhead_anchor() -> void: - ## CharacterVisual must expose get_overhead_anchor() as part of its public API. - ## This is a static assertion — no 3D assets required. - var cv := CharacterVisual.new() - auto_free(cv) - assert_bool(cv.has_method("get_overhead_anchor")).override_failure_message( - "CharacterVisual must have get_overhead_anchor() method (#712)" - ).is_true() - - -func test_overhead_anchor_is_null_before_load() -> void: - ## get_overhead_anchor() must return null before load_descriptor() is called. - ## The anchor is created during skeleton load, not at construction. - var cv := CharacterVisual.new() - auto_free(cv) - var anchor: Variant = cv.get_overhead_anchor() - assert_bool(anchor == null).override_failure_message( - "get_overhead_anchor() must be null before load_descriptor() is called" - ).is_true() - - -func test_overhead_anchor_offset_constant() -> void: - ## [ACCEPTANCE #712] If CharacterVisual exposes the overhead anchor offset - ## as a constant or via get_overhead_anchor(), the offset must be Vector3(0, 0.3, 0). - ## Verified via code inspection: _overhead_anchor.position = Vector3(0, 0.3, 0). - ## This test loads a scene and verifies if assets are present. - var cv := CharacterVisual.new() - auto_free(cv) - add_child(cv) - - # Without GLB assets available in headless, skeleton load is a no-op. - # Check that _overhead_attachment is also null before load (belt-and-suspenders). - var attachment: Variant = cv.get("_overhead_attachment") - assert_bool(attachment == null).override_failure_message( - "_overhead_attachment must be null before skeleton is loaded" - ).is_true() diff --git a/client/tests/test_sprite_integration.gd b/client/tests/test_sprite_integration.gd deleted file mode 100644 index 1a9e76e30..000000000 --- a/client/tests/test_sprite_integration.gd +++ /dev/null @@ -1,292 +0,0 @@ -## Sprint 16 #540: Sprite integration tests. -## Tests z-sorting with real sprites, 24x32 D-044 footprint within D-066 64x64 -## bounding box, sprite asset existence from #541, and fog shader independence. -## Spec refs: D-019, D-043, D-044, D-049, D-066, #540, #541. -class_name TestSpriteIntegration -extends GdUnitTestSuite - -var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") - - -func before_test() -> void: - GameState.player_entity_id = 1 - GameState.player_position = Vector2.ZERO - GameState.visible_entities = [] - - -# -- Helpers ------------------------------------------------------------------- - -func _make_entity_renderer() -> Node2D: - var renderer = Node2D.new() - renderer.set_script(EntityRendererScript) - add_child(renderer) - return renderer - - -# -- Footprint constants: D-044 spec (S16-S01, S16-S02) ----------------------- - -func test_entity_footprint_matches_d044_spec() -> void: - # S16-S01: D-044 specifies 24x32 entity footprint within 32x32 visual tile. - # (64x64 source sprite scaled to 32px runtime at 2x retina per D-066). - assert_that(EntityRenderer.ENTITY_WIDTH).override_failure_message( - "D-044: ENTITY_WIDTH must be 24px" - ).is_equal(24) - assert_that(EntityRenderer.ENTITY_HEIGHT).override_failure_message( - "D-044: ENTITY_HEIGHT must be 32px" - ).is_equal(32) - - -func test_entity_footprint_within_d066_2x2_sim_tile_bounding_box() -> void: - # S16-S02: D-066 requires entity sprite footprint contained within 2x2 sim tile - # bounding box. At 32px/tile → 64x64px max. Entity must fit to keep interaction - # range (2 sim tiles) accurate with the tilted perspective. - var tile_2x: int = Constants.TILE_SIZE * 2 - assert_that(EntityRenderer.ENTITY_WIDTH <= tile_2x).override_failure_message( - "D-066: ENTITY_WIDTH %d must fit within 2x tile width %dpx" % [ - EntityRenderer.ENTITY_WIDTH, tile_2x] - ).is_true() - assert_that(EntityRenderer.ENTITY_HEIGHT <= tile_2x).override_failure_message( - "D-066: ENTITY_HEIGHT %d must fit within 2x tile height %dpx" % [ - EntityRenderer.ENTITY_HEIGHT, tile_2x] - ).is_true() - - -func test_entity_width_fits_within_single_tile() -> void: - # S16-S03: Entity width (24) < TILE_SIZE (32) → centered within tile. - # Ensures horizontal centering offset is positive and entity doesn't overflow. - assert_that(EntityRenderer.ENTITY_WIDTH < Constants.TILE_SIZE).override_failure_message( - "Entity width must be less than TILE_SIZE for centered layout" - ).is_true() - assert_that(EntityRenderer.ENTITY_OFFSET_X >= 0.0).override_failure_message( - "ENTITY_OFFSET_X must be non-negative for horizontal centering" - ).is_true() - - -# -- Pixel position (S16-S04) ------------------------------------------------- - -func test_entity_pixel_position_at_tile_3_7() -> void: - # S16-S04: Entity at tile (3.0, 7.0) → pixel position must be - # (3 * TILE_SIZE + ENTITY_OFFSET_X, 7 * TILE_SIZE + ENTITY_OFFSET_Y). - var renderer := _make_entity_renderer() - var entity := [{"entity_id": 10, "x": 3.0, "y": 7.0, "z": 0, - "kind": {"variant": "Npc", "data": null}}] - renderer.update_entities(entity) - var node = renderer.entity_nodes[10] - var expected_x := 3.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_X - var expected_y := 7.0 * Constants.TILE_SIZE + EntityRenderer.ENTITY_OFFSET_Y - assert_that(node.position.x).override_failure_message( - "Entity x must be tile_x * TILE_SIZE + ENTITY_OFFSET_X" - ).is_equal_approx(expected_x, 0.1) - assert_that(node.position.y).override_failure_message( - "Entity y must be tile_y * TILE_SIZE + ENTITY_OFFSET_Y" - ).is_equal_approx(expected_y, 0.1) - renderer.queue_free() - - -# -- Z-sort ordering: D-049 y-based (S16-S05, S16-S06) ----------------------- - -func test_z_sort_south_entity_has_higher_pixel_y() -> void: - # S16-S05: D-049 y-sort — entity at y=8 (south) must have higher pixel.y - # than entity at y=4 (north). Godot y-sort renders higher-y on top. - # With tilted sprites, south-facing entity must visually overlap northern. - var renderer := _make_entity_renderer() - var entities := [ - {"entity_id": 20, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, - {"entity_id": 21, "x": 5.0, "y": 8.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, - ] - renderer.update_entities(entities) - var north_node = renderer.entity_nodes[20] - var south_node = renderer.entity_nodes[21] - assert_that(south_node.position.y > north_node.position.y).override_failure_message( - "Entity at y=8 must have higher pixel.y than entity at y=4 for y-sort" - ).is_true() - renderer.queue_free() - - -func test_z_sort_y_position_difference_equals_tile_size() -> void: - # S16-S06: Two entities one tile apart in y → pixel y difference = TILE_SIZE. - # Verifies position calculation is consistent for adjacent tiles. - var renderer := _make_entity_renderer() - var entities := [ - {"entity_id": 30, "x": 5.0, "y": 3.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, - {"entity_id": 31, "x": 5.0, "y": 4.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, - ] - renderer.update_entities(entities) - var node3 = renderer.entity_nodes[30] - var node4 = renderer.entity_nodes[31] - var delta_y := node4.position.y - node3.position.y - assert_that(delta_y).override_failure_message( - "Adjacent tiles must differ by exactly TILE_SIZE (%dpx) in y" % Constants.TILE_SIZE - ).is_equal_approx(float(Constants.TILE_SIZE), 0.1) - renderer.queue_free() - - -func test_z_sort_same_y_different_x_no_y_difference() -> void: - # S16-S07: Two entities at same y but different x → same pixel.y. - # Horizontal position must not affect y-sort order. - var renderer := _make_entity_renderer() - var entities := [ - {"entity_id": 40, "x": 2.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, - {"entity_id": 41, "x": 8.0, "y": 5.0, "z": 0, "kind": {"variant": "Npc", "data": null}}, - ] - renderer.update_entities(entities) - var left_node = renderer.entity_nodes[40] - var right_node = renderer.entity_nodes[41] - assert_that(left_node.position.y).override_failure_message( - "Entities at same y-tile must have same pixel.y regardless of x" - ).is_equal_approx(right_node.position.y, 0.1) - renderer.queue_free() - - -# -- Sprite assets from #541 (S16-S08, S16-S09) -------------------------------- - -func test_npc_sprite_assets_exist_for_all_cardinal_directions() -> void: - # S16-S08: #541 delivers 64px NPC sprites for all four cardinal directions. - # entity_renderer.gd must be able to load these paths. - for direction in ["north", "east", "south", "west"]: - var path := "res://assets/sprites/npc_generic_%s_64.png" % direction - assert_that(ResourceLoader.exists(path)).override_failure_message( - "NPC sprite missing: %s" % path - ).is_true() - - -func test_wall_sprite_assets_exist_for_all_cardinal_directions() -> void: - # S16-S09: #541 delivers 64px wall sprites for all four cardinal directions. - for direction in ["north", "east", "south", "west"]: - var path := "res://assets/sprites/wall_structural_%s_64.png" % direction - assert_that(ResourceLoader.exists(path)).override_failure_message( - "Wall sprite missing: %s" % path - ).is_true() - - -# -- Fog shader independence: D-019 (S16-S10, S16-S11) ----------------------- - -func test_fog_shader_script_and_gdshader_load_correctly() -> void: - # S16-S10: fog_shader.gd and fog.gdshader must remain intact after sprite - # changes. D-019: "fog vision cone math remains pure 2D" — unaffected by - # the art-direction tilt baked into sprites. - assert_that(ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")).override_failure_message( - "fog_shader.gd must load correctly — must not be affected by sprite changes" - ).is_true() - assert_that(ResourceLoader.exists("res://shaders/fog.gdshader")).override_failure_message( - "fog.gdshader must exist — fog is screen-space and sprite-independent" - ).is_true() - - -func test_fog_update_runs_independently_of_entity_renderer_state() -> void: - # S16-S11: FogState.update_from_state() must succeed with no entity renderer - # active. D-019: fog driven by LOS mask (visible_positions), not sprites. - var fog = get_node_or_null("/root/FogState") - if fog == null: - push_warning("TestSpriteIntegration: FogState not available — fog independence test skipped") - return - # Provide visibility data but no entity renderer context - GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true} - GameState.visibility_sectors = { - Vector2i(5, 5): "Forward", - Vector2i(6, 5): "Peripheral", - } - if fog.has_method("update_from_state"): - fog.update_from_state() - assert_that(fog.visibility_texture).override_failure_message( - "FogState visibility_texture must be populated independently of sprite state" - ).is_not_null() - GameState.visible_positions.clear() - GameState.visibility_sectors.clear() - - -# -- Direction mapping: _octant_to_direction (S16-S12 through S16-S21) -------- - -func test_octant_north_maps_to_north() -> void: - # S16-S12: "North" → "north" - assert_that(EntityRenderer._octant_to_direction("North")).is_equal("north") - -func test_octant_northwest_maps_to_north() -> void: - # S16-S13: "Northwest" → "north" (grouped with North per mapping spec) - assert_that(EntityRenderer._octant_to_direction("Northwest")).is_equal("north") - -func test_octant_northeast_maps_to_east() -> void: - # S16-S14: "Northeast" → "east" - assert_that(EntityRenderer._octant_to_direction("Northeast")).is_equal("east") - -func test_octant_east_maps_to_east() -> void: - # S16-S15: "East" → "east" - assert_that(EntityRenderer._octant_to_direction("East")).is_equal("east") - -func test_octant_southeast_maps_to_south() -> void: - # S16-S16: "Southeast" → "south" - assert_that(EntityRenderer._octant_to_direction("Southeast")).is_equal("south") - -func test_octant_south_maps_to_south() -> void: - # S16-S17: "South" → "south" - assert_that(EntityRenderer._octant_to_direction("South")).is_equal("south") - -func test_octant_southwest_maps_to_west() -> void: - # S16-S18: "Southwest" → "west" - assert_that(EntityRenderer._octant_to_direction("Southwest")).is_equal("west") - -func test_octant_west_maps_to_west() -> void: - # S16-S19: "West" → "west" - assert_that(EntityRenderer._octant_to_direction("West")).is_equal("west") - -func test_octant_unknown_string_falls_back_to_south() -> void: - # S16-S20: Unknown string → "south" fallback (safe default — viewer-facing per D-019) - assert_that(EntityRenderer._octant_to_direction("Unknown")).is_equal("south") - assert_that(EntityRenderer._octant_to_direction("invalid")).is_equal("south") - -func test_octant_empty_string_falls_back_to_south() -> void: - # S16-S21: Empty string → "south" fallback - assert_that(EntityRenderer._octant_to_direction("")).is_equal("south") - - -# -- Direction mapping: _entity_direction (S16-S22 through S16-S25) ---------- - -func test_entity_direction_npc_always_south() -> void: - # S16-S22: NPC entity → always "south" regardless of any data field. - # NPCs have no facing in v1 entity format; south is viewer-facing (D-019 angle). - var renderer := _make_entity_renderer() - GameState.player_entity_id = 1 - # entity_id 99 is not the player - var dir := renderer._entity_direction(99, {"entity_id": 99, - "kind": {"variant": "Npc", "data": null}}) - assert_that(dir).override_failure_message( - "NPC entity must always return 'south'" - ).is_equal("south") - renderer.queue_free() - -func test_entity_direction_player_uses_player_facing() -> void: - # S16-S23: Player entity → uses GameState.player_facing via _octant_to_direction. - var renderer := _make_entity_renderer() - GameState.player_entity_id = 1 - GameState.player_facing = "North" - var dir := renderer._entity_direction(1, {"entity_id": 1, - "kind": {"variant": "Player", "data": null}}) - assert_that(dir).override_failure_message( - "Player entity with player_facing='North' must return 'north'" - ).is_equal("north") - renderer.queue_free() - -func test_entity_direction_player_facing_east() -> void: - # S16-S24: Player facing "East" → "east" - var renderer := _make_entity_renderer() - GameState.player_entity_id = 1 - GameState.player_facing = "East" - var dir := renderer._entity_direction(1, {"entity_id": 1, - "kind": {"variant": "Player", "data": null}}) - assert_that(dir).override_failure_message( - "Player entity with player_facing='East' must return 'east'" - ).is_equal("east") - renderer.queue_free() - -func test_entity_direction_player_facing_diagonal_uses_nearest_cardinal() -> void: - # S16-S25: Player facing "Northwest" → "north" (nearest cardinal mapping). - # Diagonal octants map to one of the four cardinal sprite sets. - var renderer := _make_entity_renderer() - GameState.player_entity_id = 1 - GameState.player_facing = "Northwest" - var dir := renderer._entity_direction(1, {"entity_id": 1, - "kind": {"variant": "Player", "data": null}}) - assert_that(dir).override_failure_message( - "Player entity with player_facing='Northwest' must return 'north'" - ).is_equal("north") - renderer.queue_free() diff --git a/client/tests/test_sprite_integration.gd.uid b/client/tests/test_sprite_integration.gd.uid deleted file mode 100644 index d9f5f7931..000000000 --- a/client/tests/test_sprite_integration.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://b8snipm64g2b2 diff --git a/client/ui/dialogue_box.gd b/client/ui/dialogue_box.gd index d9895629b..5be34fc10 100644 --- a/client/ui/dialogue_box.gd +++ b/client/ui/dialogue_box.gd @@ -625,8 +625,11 @@ func _format_entry(entry: Dictionary, alpha: float) -> String: ## Escape BBCode bracket characters in server-sourced text (Hoshe #2). +## #866 fix: only escape '[' — unmatched ']' renders as a literal in RichTextLabel. +## Chaining .replace("]", "[rb]") after .replace("[", "[lb]") corrupted the [lb] escape +## itself: "[lb]" → "[lb[rb]", making the BBCode injection guard non-functional. static func _escape_bbcode(text: String) -> String: - return text.replace("[", "[lb]").replace("]", "[rb]") + return text.replace("[", "[lb]") ## Assign a palette color to an NPC entity ID on first encounter (#573). diff --git a/docs/sprints/sprint-37/client.md b/docs/sprints/sprint-37/client.md index 643923b84..3830b58a7 100644 --- a/docs/sprints/sprint-37/client.md +++ b/docs/sprints/sprint-37/client.md @@ -15,6 +15,7 @@ | #869 | Fix MetaScreen test helper regression — anti_tedium suite (7 fails) | high | — | | #870 | Delete or revive 8 parse-error test files | medium | — | | #875 | Drop PROTOCOL_VERSION on client (D-192) | medium | #874 (server) | +| #882 | Strip archetype-driven client code (follow-up to #878) | medium | #878 (server) | Use `tooling/db/ticket show ` for full details. @@ -66,10 +67,18 @@ Use `tooling/db/ticket show ` for full details. - Update any fixture-replay paths that read `version`. - Keep all field-presence and roundtrip behavioral tests. +**#882 — Strip archetype-driven client code (follow-up to server #878)** +- Added 2026-04-21. Blocked by server #878 (removes `character_archetype` from StartupMessage; rides #874's PROTOCOL_VERSION break). +- Per lead direction: the CharacterArchetype trace is Phase 6 filler, not production. Keep character-creation UI and insert screens; strip everything else. +- Strip: `character_archetype` field in `game_state.gd` (line 100), `lattice_profile` derivation (line 47); `session_manager.gd` `save_character_archetype()`, `_read_archetype_file()`, character.txt save/load (lines 58, 172–192); `protocol.gd` `character_archetype` on StartupMessage; `sim_bridge.gd` archetype wire-up; any monologue color-palette code keyed on `lattice_augmented`/`lattice_baseline`; audit `tests/client/test_signal_sprint24.gd`. +- Verify: client launches, character creation UI loads, insert screens render, session starts. Grep `character_archetype`, `lattice_profile`, `smuggler`, `detective` in `client/` — only character-creation UI references remain. +- Context: server-side audit at `docs/architecture/sprint-37-878-audit.md` (on server branch until #878 merges). + ## Dependency Chain ``` #874 (server) → #875 (client PROTOCOL_VERSION drop) +#878 (server) → #882 (client archetype strip) #872 (New Game regression fix) → #873 (merge-path tests add coverage for this flow) #866, #869, #870 → standalone, parallel ``` From d72fcc7847c90f11070c4c402e4903df027d5afb Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Tue, 21 Apr 2026 17:29:49 +0200 Subject: [PATCH 2/4] =?UTF-8?q?fix(protocol):=20drop=20PROTOCOL=5FVERSION?= =?UTF-8?q?=20lockstep=20=E2=80=94=20D-192=20(#875)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes the version-mismatch guard from Protocol.decode_snapshot() and the PROTOCOL_VERSION constant from the client (server side done in #874). Core changes: - protocol.gd: remove const PROTOCOL_VERSION, remove version mismatch guard, remove "version" from return dict, add gauntlet_mode/room_id decode - sim_bridge.gd: remove handshake version check; relax handshake guard to require only a valid Dictionary (server no longer sends protocol_version); emit handshake_complete(0) for API compat - loading_screen.gd: drop "· protocol N" suffix from version label - test_harness.gd: replace Protocol.PROTOCOL_VERSION with literal 23 Test updates (21 files): replace "version": Protocol.PROTOCOL_VERSION with "version": 23 in all snapshot bytes dicts; remove snapshot.version == N assertions; remove version-rejection tests (test_rejects_version_6, test_decode_snapshot_rejects_missing_version, test_decode_snapshot_rejects_old_version, test_protocol_rejects_version_mismatch, test_sim_bridge_test_snapshot_uses_current_protocol_version). Also includes: #872 bookmark_catalog carry-forward regression test, and #873 merge-path flow tests (test_merge_path_flows_sprint37.gd). Co-Authored-By: Claude Sonnet 4.6 --- client/scripts/autoloads/sim_bridge.gd | 32 +- client/scripts/protocol/protocol.gd | 35 +- client/scripts/protocol/test_harness.gd | 2 +- client/tests/test_anti_tedium.gd | 24 +- client/tests/test_bug_report_ring_buffer.gd | 10 +- client/tests/test_hub_teleport.gd | 4 +- client/tests/test_input_roundtrip.gd | 3 - client/tests/test_interaction_list.gd | 4 +- client/tests/test_interaction_prompt.gd | 29 +- client/tests/test_ipc_fixtures.gd | 7 - client/tests/test_local_bridge.gd | 2 +- .../tests/test_merge_path_flows_sprint37.gd | 317 ++++++++++++++++++ client/tests/test_p0_regressions.gd | 78 ++++- client/tests/test_protocol.gd | 32 +- client/tests/test_protocol_bridge.gd | 52 +-- client/tests/test_protocol_v7.gd | 23 +- client/tests/test_rendering.gd | 2 - client/tests/test_signal_sprint24.gd | 18 +- client/tests/test_sprint2_proof.gd | 1 - client/tests/test_time_display_sprint17.gd | 2 +- .../ui/meta/screens/loading/loading_screen.gd | 6 +- 21 files changed, 490 insertions(+), 193 deletions(-) create mode 100644 client/tests/test_merge_path_flows_sprint37.gd diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 1e8758502..0c297e519 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -246,13 +246,10 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns if msg.is_empty(): return # Not ready yet, continue polling - # Decode HandshakeMessage: { "protocol_version": N } + # Decode HandshakeMessage — D-192 (#875): protocol_version field dropped. + # Server sends {} or a minimal dict; only structural validity is required. var decoded: Variant = Messagepack.decode(msg) - if ( - decoded.status != null - or not (decoded.value is Dictionary) - or not decoded.value.has("protocol_version") - ): + if decoded.status != null or not (decoded.value is Dictionary): var reason := "Handshake decode failed: malformed HandshakeMessage" push_error("SimBridge: %s" % reason) handshake_failed.emit(reason) @@ -260,18 +257,6 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns _set_state(ConnectionState.ERROR) return - var server_version: int = decoded.value["protocol_version"] - if server_version != Protocol.PROTOCOL_VERSION: - var reason := ( - "Protocol version mismatch: server=%d, client=%d" - % [server_version, Protocol.PROTOCOL_VERSION] - ) - push_error("SimBridge: %s" % reason) - handshake_failed.emit(reason) - _bridge.disconnect_from_server() - _set_state(ConnectionState.ERROR) - return - # Send startup message with world_seed and character appearance (#175, D-010/D-029, #718). # Server blocks waiting for this before entering the tick loop. var startup_bytes := Protocol.encode_startup_message( @@ -296,7 +281,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns _set_state(ConnectionState.ERROR) return - handshake_complete.emit(server_version) + handshake_complete.emit(0) # D-192: protocol_version field dropped; signal kept for API compat _set_state(ConnectionState.CONNECTED) # #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled # from server SQLite so the client reflects the authoritative persisted state (D-138). @@ -464,6 +449,15 @@ func receive_bytes(bytes: PackedByteArray) -> void: and _last_snapshot.get("settings_response") != null ): snapshot["settings_response"] = _last_snapshot["settings_response"] + # #872: Carry forward bookmark_catalog (one-shot, consumed by main_menu._on_snapshot_received_for_catalog). + # Server sends catalog on tick 0 and after RequestBookmarkCatalog. If tick 0 and tick 1 + # arrive in the same TCP batch, the inner receive loop overwrites _last_snapshot and the + # catalog is silently lost — this carry-forward prevents that race. + if ( + snapshot.get("bookmark_catalog") == null + and _last_snapshot.get("bookmark_catalog") != null + ): + snapshot["bookmark_catalog"] = _last_snapshot["bookmark_catalog"] _last_snapshot = snapshot diff --git a/client/scripts/protocol/protocol.gd b/client/scripts/protocol/protocol.gd index f8230bfba..8180bf935 100644 --- a/client/scripts/protocol/protocol.gd +++ b/client/scripts/protocol/protocol.gd @@ -9,13 +9,6 @@ extends Node ## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc") ## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"}) -## Protocol version — must match server PROTOCOL_VERSION in bridge/types.rs. -## Reject snapshots where version != this value. -## v20: adds settings_response field to ObserverSnapshot (#627, D-138). -## v21: adds economy_snapshot field to ObserverSnapshot (#822, D-181). -## v23: adds bookmark_catalog field to ObserverSnapshot (#614). -const PROTOCOL_VERSION: int = 23 - # -- Decode: bytes from server → GDScript types -------------------------------- @@ -34,17 +27,6 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: push_error("Protocol: snapshot missing required fields") return null - # Version check: reject snapshots from incompatible server - var version: Variant = raw.get("version") - if version != PROTOCOL_VERSION: - push_error( - ( - "Protocol: version mismatch (got %s, expected %s). Server and client are out of sync." - % [version, PROTOCOL_VERSION] - ) - ) - return null - var entities: Array[Dictionary] = [] var raw_entities: Array = raw["entities"] var dropped := 0 @@ -67,7 +49,7 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: # in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031). var tick: int = raw["tick"] - # version already checked above; game_time for HUD display + # game_time for HUD display var game_time: Variant = raw.get("game_time") # player_facing: FacingDirection is a unit enum → bare string in rmp_serde @@ -223,6 +205,18 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "speaker_entity_id": int(raw_dr.get("speaker_entity_id", -1)), } + # v8: gauntlet_mode and room_id (#496) — present only in Gauntlet sessions. + # gauntlet_mode is a bool flag; room_id is a String room identifier or absent. + # Snapshot handler (snapshot_handler.gd) reads these via snapshot.has() guards. + var gauntlet_mode: bool = false + var raw_gauntlet: Variant = raw.get("gauntlet_mode") + if raw_gauntlet == true: + gauntlet_mode = true + var room_id: Variant = null + var raw_room_id: Variant = raw.get("room_id") + if raw_room_id is String: + room_id = raw_room_id + # v9: conversation_events (#535, D-078) — overheard NPC-to-NPC dialogue lines. # Each event carries pre-occluded text plus speaker/target attribution. var conversation_events: Array = [] @@ -484,7 +478,6 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "tick": tick, "entities": entities, "decode_errors": dropped, - "version": version, "game_time": game_time, "player_facing": player_facing, "player_stance": player_stance, @@ -508,6 +501,8 @@ static func decode_snapshot(bytes: PackedByteArray) -> Variant: "current_ticker": current_ticker, "settings_response": settings_response, "bookmark_catalog": bookmark_catalog, + "gauntlet_mode": gauntlet_mode, + "room_id": room_id, } diff --git a/client/scripts/protocol/test_harness.gd b/client/scripts/protocol/test_harness.gd index cf6cbffdf..b2b7e2e34 100644 --- a/client/scripts/protocol/test_harness.gd +++ b/client/scripts/protocol/test_harness.gd @@ -272,7 +272,7 @@ func snapshot() -> Dictionary: return { "tick": tick, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "game_time": { "day": 0, diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd index 22d4766e0..b16d5ae48 100644 --- a/client/tests/test_anti_tedium.gd +++ b/client/tests/test_anti_tedium.gd @@ -15,7 +15,7 @@ extends GdUnitTestSuite const MAIN_SCENE = preload("res://scenes/main.tscn") var GauntletHUDScript = load("res://ui/gauntlet_hud.gd") -var BugReportDialogScript = load("res://ui/bug_report_dialog.gd") +const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn") var _instance: Node = null @@ -47,7 +47,7 @@ func after_test() -> void: func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: var snapshot := { "tick": overrides.get("tick", 1), - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": overrides.get("entities", [{ "entity_id": 1, "x": 10.0, @@ -92,8 +92,10 @@ func _make_gauntlet_hud() -> Control: func _make_bug_report_dialog() -> Control: - var dialog = Control.new() - dialog.set_script(BugReportDialogScript) + # Instantiate via .tscn — preserves the MetaScreen runtime stack. + # (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare + # Control.new() + set_script() no longer satisfies the base contract.) + var dialog: Control = BugReportDialogScene.instantiate() auto_free(dialog) add_child(dialog) return dialog @@ -203,13 +205,15 @@ func test_snapshot_without_room_id_shows_no_gauntlet_ui() -> void: var snapshot: Variant = SimBridge._last_snapshot assert_that(snapshot).is_not_null() - # Snapshot should NOT contain gauntlet fields - assert_that(snapshot.has("room_id")).override_failure_message( - "Non-gauntlet snapshot must not contain room_id" - ).is_false() - assert_that(snapshot.has("gauntlet_mode")).override_failure_message( - "Non-gauntlet snapshot must not contain gauntlet_mode" + # Non-gauntlet snapshot: gauntlet fields must be present with default values. + # (Protocol.decode_snapshot always decodes gauntlet fields; non-gauntlet + # snapshots produce false/null defaults. Check values, not key presence.) + assert_that(snapshot.get("gauntlet_mode", false)).override_failure_message( + "Non-gauntlet snapshot must decode gauntlet_mode == false" ).is_false() + assert_that(snapshot.get("room_id")).override_failure_message( + "Non-gauntlet snapshot must decode room_id == null" + ).is_null() # Apply to GameState — gauntlet-related state should not exist GameState.apply_snapshot(snapshot) diff --git a/client/tests/test_bug_report_ring_buffer.gd b/client/tests/test_bug_report_ring_buffer.gd index eaacf3a1a..c8fc62f24 100644 --- a/client/tests/test_bug_report_ring_buffer.gd +++ b/client/tests/test_bug_report_ring_buffer.gd @@ -14,7 +14,7 @@ extends GdUnitTestSuite # Expected ring buffer capacity per spec. const EXPECTED_CAPACITY := 60 -var BugReportDialogScript = load("res://ui/bug_report_dialog.gd") +const BugReportDialogScene = preload("res://ui/bug_report_dialog.tscn") func after_each() -> void: @@ -35,8 +35,10 @@ func after_each() -> void: # -- Helpers ------------------------------------------------------------------- func _make_dialog() -> Control: - var dialog = Control.new() - dialog.set_script(BugReportDialogScript) + # Instantiate via .tscn — preserves the MetaScreen runtime stack. + # (Sprint 36 migrated bug_report_dialog.gd to extends MetaScreen; bare + # Control.new() + set_script() no longer satisfies the base contract.) + var dialog: Control = BugReportDialogScene.instantiate() auto_free(dialog) add_child(dialog) return dialog @@ -52,7 +54,7 @@ func _make_input(tick: int, action: String = "MoveNorth") -> Dictionary: func _make_snapshot_json(tick: int) -> String: return JSON.stringify({ "tick": tick, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], }) diff --git a/client/tests/test_hub_teleport.gd b/client/tests/test_hub_teleport.gd index e39366f5b..3d1fedc58 100644 --- a/client/tests/test_hub_teleport.gd +++ b/client/tests/test_hub_teleport.gd @@ -15,7 +15,7 @@ const _THRESHOLD: float = 5.0 # Mirror of main.gd TELEPORT_DISTANCE_THRESHOLD var _gauntlet_snapshot := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, "player_facing": "North", "player_stance": "Walk", @@ -36,7 +36,7 @@ var _gauntlet_snapshot := { var _normal_snapshot := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "game_time": {"day": 0, "time_of_day": 100, "day_phase": "Morning", "tick_rate": "Full"}, "player_facing": "North", "player_stance": "Walk", diff --git a/client/tests/test_input_roundtrip.gd b/client/tests/test_input_roundtrip.gd index 3a99fec44..ac58eeb74 100644 --- a/client/tests/test_input_roundtrip.gd +++ b/client/tests/test_input_roundtrip.gd @@ -160,8 +160,6 @@ func test_interact_roundtrip() -> void: # Server accepts it (currently a no-op) and responds with a valid snapshot. var snapshot: Dictionary = await _send_and_receive("Interact", 0) - # Snapshot should be valid with correct protocol version - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.entities.size()).is_greater(0) # Player should be at start position (Interact doesn't move) @@ -173,7 +171,6 @@ func test_interact_roundtrip() -> void: # Send Interact again at next tick — server should still accept it var snap2: Dictionary = await _send_and_receive("Interact", 1) assert_that(snap2).is_not_null() - assert_that(snap2.version).is_equal(Protocol.PROTOCOL_VERSION) # -- Mixed sequence: movement then interact in one session --------------------- diff --git a/client/tests/test_interaction_list.gd b/client/tests/test_interaction_list.gd index 5f4cd3f3b..76f5c34fa 100644 --- a/client/tests/test_interaction_list.gd +++ b/client/tests/test_interaction_list.gd @@ -305,7 +305,7 @@ func test_contradicted_entity_verbs_decode() -> void: # #422: NearbyInteraction.contradicted=true should be decodeable var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "nearby_interactions": [{ "entity_id": 2, @@ -329,7 +329,7 @@ func test_object_type_verbs_decode() -> void: # #421: ObjectType appears in NearbyInteraction.object_type var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "nearby_interactions": [{ "entity_id": 3, diff --git a/client/tests/test_interaction_prompt.gd b/client/tests/test_interaction_prompt.gd index 7e3557b3d..51cdfdb51 100644 --- a/client/tests/test_interaction_prompt.gd +++ b/client/tests/test_interaction_prompt.gd @@ -11,7 +11,7 @@ extends GdUnitTestSuite func test_protocol_decode_v4_with_nearby_interactions() -> void: var raw := { "tick": 10, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [ {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player", "visibility": "Forward", "relationship": "Unknown", "observation": "Visible"}, @@ -46,7 +46,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void: func test_protocol_decode_v4_no_nearby_interactions() -> void: var raw := { "tick": 5, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -57,7 +57,7 @@ func test_protocol_decode_v4_no_nearby_interactions() -> void: func test_protocol_decode_empty_nearby_interactions() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "nearby_interactions": [], } @@ -68,7 +68,7 @@ func test_protocol_decode_empty_nearby_interactions() -> void: func test_protocol_decode_interaction_missing_verbs() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "nearby_interactions": [{"entity_id": 2}], } @@ -79,7 +79,7 @@ func test_protocol_decode_interaction_missing_verbs() -> void: func test_protocol_decode_interaction_empty_verbs() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}], } @@ -90,7 +90,7 @@ func test_protocol_decode_interaction_empty_verbs() -> void: func test_protocol_decode_v4_entity_relationship() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [ {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc", "visibility": "Forward", "relationship": "Friendly", "observation": "Visible"}, @@ -100,17 +100,6 @@ func test_protocol_decode_v4_entity_relationship() -> void: var snapshot = Protocol.decode_snapshot(encoded.value) assert_that(snapshot.entities[0].relationship).is_equal("Friendly") -func test_protocol_rejects_version_mismatch() -> void: - var raw := { - "tick": 5, - "version": 2, - "entities": [], - } - var encoded = Messagepack.encode(raw) - var snapshot = Protocol.decode_snapshot(encoded.value) - assert_that(snapshot).is_null() - - # -- GameState: nearby_interactions storage -- func test_game_state_stores_nearby_interactions() -> void: @@ -153,12 +142,6 @@ func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void: var snap = SimBridge._test_snapshot() assert_that(snap.nearby_interactions.size()).is_equal(1) -func test_sim_bridge_test_snapshot_protocol_version() -> void: - SimBridge.reset_test_state() - var snap = SimBridge._test_snapshot() - assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION) - - # -- InteractionPrompt UI -- func test_prompt_get_selected_verb_returns_first_kind() -> void: diff --git a/client/tests/test_ipc_fixtures.gd b/client/tests/test_ipc_fixtures.gd index 12594b279..d95085882 100644 --- a/client/tests/test_ipc_fixtures.gd +++ b/client/tests/test_ipc_fixtures.gd @@ -19,13 +19,6 @@ func _load_fixture(name: String) -> PackedByteArray: # -- snapshot_minimal ---------------------------------------------------------- -func test_fixture_snapshot_minimal_version() -> void: - var bytes = _load_fixture("snapshot_minimal") - var snapshot = Protocol.decode_snapshot(bytes) - assert_that(snapshot).is_not_null() - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) - - func test_fixture_snapshot_minimal_tick() -> void: var bytes = _load_fixture("snapshot_minimal") var snapshot = Protocol.decode_snapshot(bytes) diff --git a/client/tests/test_local_bridge.gd b/client/tests/test_local_bridge.gd index 9bc822498..afcfb4f14 100644 --- a/client/tests/test_local_bridge.gd +++ b/client/tests/test_local_bridge.gd @@ -95,7 +95,7 @@ func test_frame_encode_large_payload_length() -> void: func test_framed_protocol_snapshot_roundtrip() -> void: # Encode a snapshot with Protocol, frame it, decode the frame, decode the snapshot - var snapshot_data := {"tick": 42, "version": Protocol.PROTOCOL_VERSION, "entities": []} + var snapshot_data := {"tick": 42, "version": 23, "entities": []} var encoded: Variant = Messagepack.encode(snapshot_data) assert_that(encoded.status).is_null() diff --git a/client/tests/test_merge_path_flows_sprint37.gd b/client/tests/test_merge_path_flows_sprint37.gd new file mode 100644 index 000000000..2038e0e0c --- /dev/null +++ b/client/tests/test_merge_path_flows_sprint37.gd @@ -0,0 +1,317 @@ +## Sprint 37 — Scene-level merge-path UI flow tests (#873) +## +## Four flows that cover the critical paths through the pre-game UI. +## These tests are the merge-gate mechanism added in the Sprint 36 retro: +## regressions like #872 (New Game hang) must be caught here, not in post-merge +## smoke tests. +## +## Pattern: load scene → simulate input via button.pressed.emit() or direct +## handler call → assert terminal state. No pixel diffing, no xdotool. +## +## NOTE: Tests that end in a scene transition (change_scene_to_file) assert state +## synchronously before the deferred transition fires. The test scene is +## queue_freed in after_test() regardless. +## +## Reference: test_character_creation_sprint28.gd +## Ticket: #873 | motivating regression: #872 +class_name TestMergePathFlowsSprint37 +extends GdUnitTestSuite + +const MAIN_MENU_SCENE_PATH := "res://scenes/main_menu.tscn" +const CHAR_CREATE_SCENE_PATH := "res://scenes/character_creation.tscn" + +var _scene = null # MainMenu or CharacterCreation — untyped, varies per test + + +func before_test() -> void: + # Reset all shared state that these flows touch + SimBridge.disconnect_from_sim() + SimBridge._last_snapshot = null + SimBridge._outbound_buffer.clear() + GameState.bookmark_catalog = [] + GameState.pending_load_path = "" + # Clear MetaStack from any leftover overlays to prevent push/pop ordering issues + MetaStack._stack.clear() + + +func after_test() -> void: + if is_instance_valid(_scene): + _scene.queue_free() + _scene = null + SimBridge.disconnect_from_sim() + SimBridge._outbound_buffer.clear() + MetaStack._stack.clear() + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- + +func _load_main_menu() -> void: + var packed := load(MAIN_MENU_SCENE_PATH) as PackedScene + if packed == null: + push_warning("TestMergePathFlowsSprint37: main_menu.tscn not found — skipping") + return + _scene = packed.instantiate() + add_child(_scene) + + +func _load_char_create() -> void: + var packed := load(CHAR_CREATE_SCENE_PATH) as PackedScene + if packed == null: + push_warning("TestMergePathFlowsSprint37: character_creation.tscn not found — skipping") + return + _scene = packed.instantiate() + # Seed required state so Start is not disabled (guard added in PR #134 / R2-Hoshe-1). + # Individual tests override these as needed. + _scene._selected_bookmark_id = "test-bookmark" + _scene._selected_location_id = "test-location" + if _scene.has_method("_update_start_btn_state"): + _scene._update_start_btn_state() + add_child(_scene) + + +func _make_catalog_snapshot() -> Dictionary: + ## Minimal valid snapshot with a bookmark_catalog for flow-1 testing. + return { + "tick": 0, + "version": 23, + "entities": [], + "game_time": {"day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": "Full"}, + "player_facing": "North", + "player_stance": "Walk", + "player_inventory": [], + "visible_tiles": [], + "nearby_interactions": [], + "pending_recognitions": [], + "bookmark_catalog": { + "bookmarks": [ + { + "id": "bm_tycoon_arion", + "title": "Arion Freight Broker", + "subtitle": "Start at Arion orbital", + "flavor": "Commodities and logistics.", + "default_location": "arion", + "allowed_locations": ["arion", "arion_low"], + "allowed_locations_cultures": ["arion"], + "career": "tycoon", + "starting_capital_tractus": 50000, + } + ] + }, + } + + +# ============================================================================= +# Flow 1: main menu → new game → loading state → catalog received → resolved +# ============================================================================= +# Regression guard for #872: New Game used to hang on "Connecting to simulation..." +# because bookmark_catalog was overwritten in receive_bytes before poll_snapshot consumed it. +# This test catches that regression by verifying the full state machine: +# pressed → loading visible → catalog signal → loading dismissed. + +func test_new_game_shows_loading_screen() -> void: + ## Pressing New Game must show the loading screen and set _waiting_for_catalog. + _load_main_menu() + if _scene == null: + return + + assert_bool(_scene._waiting_for_catalog).override_failure_message( + "_waiting_for_catalog must be false before New Game is pressed" + ).is_false() + + # Press New Game via the button signal (same as real player input) + _scene._new_game_btn.pressed.emit() + + assert_bool(_scene._waiting_for_catalog).override_failure_message( + "_waiting_for_catalog must be true after New Game pressed" + ).is_true() + assert_that(_scene._loading_screen).override_failure_message( + "Loading screen instance must exist after New Game pressed" + ).is_not_null() + assert_bool(_scene._loading_screen.visible).override_failure_message( + "Loading screen must be visible after New Game pressed" + ).is_true() + + +func test_new_game_catalog_snapshot_resolves_loading_state() -> void: + ## When snapshot_received fires with a bookmark_catalog, the loading state must clear. + ## This is the exact regression introduced in #872 — if the catalog is never delivered, + ## _waiting_for_catalog stays true and the screen hangs forever. + _load_main_menu() + if _scene == null: + return + + # Simulate the New Game press to set up the signal subscription and loading state. + # In test mode, connect_to_sim() immediately fires CONNECTED, which triggers + # _on_sim_state_changed_for_new_game and connects snapshot_received. + _scene._new_game_btn.pressed.emit() + + assert_bool(_scene._waiting_for_catalog).override_failure_message( + "Precondition: _waiting_for_catalog must be true before catalog arrives" + ).is_true() + + # Deliver the catalog via the signal path (same path the server uses in live mode). + # snapshot_received is emitted here directly because in test mode poll_snapshot() + # uses the harness snapshot (no catalog). The carry-forward fix (#872) ensures + # this signal path also works correctly in live mode when ticks batch. + var catalog_snapshot := _make_catalog_snapshot() + SimBridge.snapshot_received.emit(catalog_snapshot) + + # Terminal state: loading resolved + assert_bool(_scene._waiting_for_catalog).override_failure_message( + "_waiting_for_catalog must be false after catalog snapshot delivered — #872 regression" + ).is_false() + assert_bool(GameState.bookmark_catalog.size() > 0).override_failure_message( + "GameState.bookmark_catalog must be populated after catalog snapshot applied" + ).is_true() + assert_str(GameState.bookmark_catalog[0].get("id", "")).override_failure_message( + "Catalog entry must have the expected bookmark id" + ).is_equal("bm_tycoon_arion") + + +# ============================================================================= +# Flow 2: main menu → load game → save picker → save selected +# ============================================================================= + +func test_load_game_save_picker_shows_on_browse() -> void: + ## Calling _on_load_game_browse() must show the save picker panel. + _load_main_menu() + if _scene == null: + return + + assert_bool(_scene._load_panel.visible).override_failure_message( + "Load panel must be hidden before Load Game is pressed" + ).is_false() + + _scene._on_load_game_browse() + + assert_bool(_scene._load_panel.visible).override_failure_message( + "Load panel must be visible after _on_load_game_browse()" + ).is_true() + + +func test_load_game_save_selection_sets_pending_load_path() -> void: + ## Selecting a save entry must set GameState.pending_load_path for main.gd to consume. + _load_main_menu() + if _scene == null: + return + + var mock_save := { + "game_id": "20260421-120000-abc123", + "newest_save": "quicksave.sav", + } + + # Call _on_save_selected directly — mirrors what the generated save-list button does. + _scene._on_save_selected(mock_save) + + assert_str(GameState.pending_load_path).override_failure_message( + "pending_load_path must be set to the selected save's full path" + ).is_equal("user://saves/20260421-120000-abc123/quicksave.sav") + + +# ============================================================================= +# Flow 3: character creation → submit → sim_bridge receives correct payload +# ============================================================================= + +func test_character_creation_submit_sends_confirm_bookmark_action() -> void: + ## _on_start() must queue a ConfirmBookmark action with the selected bookmark + ## and location IDs. This is the payload the server uses to initialize the run. + _load_char_create() + if _scene == null: + return + + # Ensure SimBridge is connected so send_named_action doesn't silently drop the action + SimBridge.connect_to_sim() # test mode: immediately CONNECTED + + _scene._selected_bookmark_id = "bm_tycoon_arion" + _scene._selected_location_id = "arion" + if _scene.has_method("_update_start_btn_state"): + _scene._update_start_btn_state() + + SimBridge._outbound_buffer.clear() + _scene._on_start() + + var found := false + for entry in SimBridge._outbound_buffer: + if entry.get("action_name") == "ConfirmBookmark": + var data: Variant = entry.get("action_data") + if ( + data is Dictionary + and data.get("bookmark_id") == "bm_tycoon_arion" + and data.get("starting_location_id") == "arion" + ): + found = true + break + assert_bool(found).override_failure_message( + "_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion'}" + ).is_true() + + +# ============================================================================= +# Flow 4: bookmark tab → select location → confirm → server gets bookmark action +# ============================================================================= + +func test_bookmark_tab_select_location_then_confirm_queues_action() -> void: + ## Exercises the full selection path: picking a bookmark, picking a location, then + ## confirming. Verifies the correct bookmark_id + starting_location_id reach the server. + ## This is the flow the player actually takes — selection handlers must propagate + ## to the outbound buffer correctly. + + # Populate catalog before scene instantiation so _build_bookmark_cards() sees it + GameState.bookmark_catalog = [ + { + "id": "bm_tycoon_arion", + "title": "Arion Freight Broker", + "subtitle": "Start at Arion orbital", + "flavor": "Commodities and logistics.", + "default_location": "arion", + "allowed_locations": ["arion", "arion_low"], + "allowed_locations_cultures": ["arion"], + "career": "tycoon", + "starting_capital_tractus": 50000, + } + ] + + _load_char_create() + if _scene == null: + return + + SimBridge.connect_to_sim() # test mode: immediately CONNECTED + SimBridge._outbound_buffer.clear() + + # Simulate the player selecting the bookmark card + var bm: Dictionary = GameState.bookmark_catalog[0] + _scene._on_bookmark_selected(bm) + + assert_str(_scene._selected_bookmark_id).override_failure_message( + "_on_bookmark_selected must update _selected_bookmark_id" + ).is_equal("bm_tycoon_arion") + assert_str(_scene._selected_location_id).override_failure_message( + "_on_bookmark_selected must populate _selected_location_id from default_location" + ).is_not_empty() + + # Simulate the player picking a specific allowed location + _scene._on_location_selected("arion_low") + + assert_str(_scene._selected_location_id).override_failure_message( + "_on_location_selected must update _selected_location_id" + ).is_equal("arion_low") + + # Confirm — sends the action to the server + _scene._on_start() + + var found := false + for entry in SimBridge._outbound_buffer: + if entry.get("action_name") == "ConfirmBookmark": + var data: Variant = entry.get("action_data") + if ( + data is Dictionary + and data.get("bookmark_id") == "bm_tycoon_arion" + and data.get("starting_location_id") == "arion_low" + ): + found = true + break + assert_bool(found).override_failure_message( + "_outbound_buffer must contain ConfirmBookmark{bookmark_id='bm_tycoon_arion', starting_location_id='arion_low'}" + ).is_true() diff --git a/client/tests/test_p0_regressions.gd b/client/tests/test_p0_regressions.gd index 845eec824..3b4855da0 100644 --- a/client/tests/test_p0_regressions.gd +++ b/client/tests/test_p0_regressions.gd @@ -1,10 +1,14 @@ -## Client P0 regression tests: guards for Bug #5 (monologue lost) and Bug #2 (camera drift). +## Client P0 regression tests: guards for Bug #5 (monologue lost), Bug #2 (camera drift), +## and Bug #872 (bookmark_catalog lost on batch receive). ## These must pass before any other client testing is meaningful. ## ## Bug #5: Monologue text lost when server sends snapshots faster than client ## consumes them. Fix: carry-forward one-shot events in receive_bytes(). ## Bug #2: Camera doesn't center at startup / drifts during pause. Fix: anchor ## pattern with smoothing disabled until first snapshot applied. +## Bug #872: bookmark_catalog silently dropped when tick 0 (with catalog) and tick 1 +## (without catalog) arrive in the same TCP batch. Fix: carry-forward +## bookmark_catalog in receive_bytes() like save_result/settings_response. ## ## Spec ref: stig-round3.md Section 1 (P0 tests #1, #2). class_name TestP0Regressions @@ -44,7 +48,7 @@ func after_test() -> void: func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: var snapshot := { "tick": overrides.get("tick", 1), - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": overrides.get("entities", [{ "entity_id": 1, "x": 10.0, @@ -71,6 +75,8 @@ func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: snapshot["current_monologue"] = overrides["current_monologue"] if overrides.has("current_dialogue"): snapshot["current_dialogue"] = overrides["current_dialogue"] + if overrides.has("bookmark_catalog"): + snapshot["bookmark_catalog"] = overrides["bookmark_catalog"] var result = Messagepack.encode(snapshot) return result.value @@ -229,3 +235,71 @@ func test_camera_anchored_after_pause_unpause() -> void: # Camera still tracking player (position may have changed due to test_snapshot) var player_pos := GameState.player_position * Constants.TILE_SIZE assert_that(camera.global_position).is_equal(player_pos) + + +# -- Bug #872: bookmark_catalog carry-forward --------------------------------- +# Server sends bookmark_catalog on tick 0. If tick 1 arrives before poll_snapshot() +# is called (same TCP batch), the inner receive loop overwrites _last_snapshot and +# the catalog is silently lost. The carry-forward fix must preserve the catalog. + +func test_bookmark_catalog_not_lost_on_snapshot_overwrite() -> void: + var catalog := { + "bookmarks": [ + { + "id": "bm_tycoon_arion", + "title": "Arion Freight Broker", + "subtitle": "Start at Arion orbital", + "flavor": "Commodities and logistics.", + "default_location": "arion", + "allowed_locations": ["arion"], + "allowed_locations_cultures": ["arion"], + "career": "tycoon", + "starting_capital_tractus": 50000, + } + ] + } + + # Tick 0: server sends catalog automatically after handshake + var bytes_tick0 := _make_snapshot_bytes({ + "tick": 0, + "bookmark_catalog": catalog, + }) + SimBridge.receive_bytes(bytes_tick0) + + # Tick 1: server sends next tick WITHOUT catalog (fast server, same TCP batch) + var bytes_tick1 := _make_snapshot_bytes({"tick": 1}) + SimBridge.receive_bytes(bytes_tick1) + + # Assert: catalog must survive the overwrite — this is the Bug #872 fix. + assert_that(SimBridge._last_snapshot).is_not_null() + var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog") + assert_that(bmc).is_not_null() + assert_that(bmc is Dictionary).is_true() + var bookmarks: Variant = bmc.get("bookmarks") + assert_that(bookmarks is Array).is_true() + assert_that((bookmarks as Array).size()).is_equal(1) + assert_that((bookmarks as Array)[0].get("id")).is_equal("bm_tycoon_arion") + + +func test_bookmark_catalog_not_carried_forward_after_consumption() -> void: + # After poll_snapshot() consumes the catalog, the next snapshot without catalog + # must NOT carry it forward (it was already consumed and the scene transitioned). + var catalog := { + "bookmarks": [{"id": "bm_test", "title": "Test", "subtitle": "", "flavor": "", + "default_location": "arion", "allowed_locations": [], "allowed_locations_cultures": [], + "career": "tycoon", "starting_capital_tractus": 0}] + } + var bytes_tick0 := _make_snapshot_bytes({"tick": 0, "bookmark_catalog": catalog}) + SimBridge.receive_bytes(bytes_tick0) + + # Simulate poll_snapshot() consumption — sets _last_snapshot to null + var snapshot = SimBridge._last_snapshot + SimBridge._last_snapshot = null + assert_that(snapshot).is_not_null() + + # Next snapshot arrives without catalog — no carry-forward should happen + var bytes_tick1 := _make_snapshot_bytes({"tick": 1}) + SimBridge.receive_bytes(bytes_tick1) + + var bmc: Variant = SimBridge._last_snapshot.get("bookmark_catalog") + assert_that(bmc).is_null() diff --git a/client/tests/test_protocol.gd b/client/tests/test_protocol.gd index cb53e9d55..be744df71 100644 --- a/client/tests/test_protocol.gd +++ b/client/tests/test_protocol.gd @@ -188,7 +188,7 @@ func test_decode_snapshot_malformed_entities_counted() -> void: # Snapshot with one valid and one malformed entity — decode_errors should count the bad one var raw := { "tick": 7, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [ {"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"}, {"entity_id": 2, "broken": true}, # Missing required fields @@ -282,7 +282,6 @@ func test_decode_snapshot_v2_full() -> void: assert_that(snapshot).is_not_null() assert_that(snapshot.tick).is_equal(500) - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) # game_time assert_that(snapshot.game_time).is_not_null() @@ -309,7 +308,6 @@ func test_existing_fixtures_have_v2_fields() -> void: var bytes = _load_fixture(fixture_name) var snapshot = Protocol.decode_snapshot(bytes) assert_that(snapshot).is_not_null() - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("North") assert_that(snapshot.game_time).is_not_null() @@ -324,30 +322,6 @@ func test_multi_entity_visibility_sectors() -> void: assert_that(snapshot.entities[3].visibility).is_equal("Forward") -# -- Version enforcement (strict PROTOCOL_VERSION check) -------------------- - -func test_decode_snapshot_rejects_missing_version() -> void: - # Snapshot without version field → rejected by strict version check - var v1_raw := {"tick": 10, "entities": [ - {"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player"}, - ]} - var encoded: Variant = Messagepack.encode(v1_raw) - assert_that(encoded.status).is_null() - - var snapshot: Variant = Protocol.decode_snapshot(encoded.value) - assert_that(snapshot).is_null() - - -func test_decode_snapshot_rejects_old_version() -> void: - # Snapshot with version 2 → rejected by strict version check - var old_raw := {"tick": 10, "version": 2, "entities": []} - var encoded: Variant = Messagepack.encode(old_raw) - assert_that(encoded.status).is_null() - - var snapshot: Variant = Protocol.decode_snapshot(encoded.value) - assert_that(snapshot).is_null() - - # -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ---------------- func test_decode_batch_input_fixture() -> void: @@ -391,7 +365,7 @@ func test_decode_snapshot_with_bookmark_catalog() -> void: # Hand-built dict — fixture generation requires server work, skip round-trip (#614). var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "bookmark_catalog": { "bookmarks": [ @@ -451,7 +425,7 @@ func test_decode_snapshot_no_bookmark_catalog_is_null() -> void: # Snapshot without bookmark_catalog key → field should be null. var raw := { "tick": 2, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded: Variant = Messagepack.encode(raw) diff --git a/client/tests/test_protocol_bridge.gd b/client/tests/test_protocol_bridge.gd index 383a355e0..91b9b393d 100644 --- a/client/tests/test_protocol_bridge.gd +++ b/client/tests/test_protocol_bridge.gd @@ -24,33 +24,12 @@ func _load_fixture(name: String) -> PackedByteArray: return file.get_buffer(file.get_length()) -# -- Protocol version upgrade ------------------------------------------------- -# Tautological "PROTOCOL_VERSION == N" assertions deleted: they assert a constant -# equals its own literal, fail mechanically on every protocol bump, and have -# never caught a real bug. Mismatch handling is exercised by test_rejects_version_6 -# below; field-presence is exercised by the per-version decode tests. - -func test_fixtures_at_protocol_version_8() -> void: - # NOTE: These binary fixtures embed version 8 and are rejected by the version - # mismatch guard in decode_snapshot(). This test is pre-existing broken since v9+. - # Fixtures need regeneration via `make fixtures-gauntlet` to match current protocol. - # Skipping rather than deleting to preserve the fixture round-trip pattern. - pass - - -func test_rejects_version_6() -> void: - var raw := {"tick": 1, "version": 6, "entities": []} - var encoded = Messagepack.encode(raw) - var snapshot = Protocol.decode_snapshot(encoded.value) - assert_that(snapshot).is_null() - - # -- player_stance decode (D-053) --------------------------------------------- func test_decode_player_stance_walk() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_stance": "Walk", "player_inventory": [], @@ -64,7 +43,7 @@ func test_decode_player_stance_walk() -> void: func test_decode_player_stance_sprint() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_stance": "Sprint", "player_inventory": [], @@ -77,7 +56,7 @@ func test_decode_player_stance_sprint() -> void: func test_decode_player_stance_careful() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_stance": "Careful", "player_inventory": [], @@ -90,7 +69,7 @@ func test_decode_player_stance_careful() -> void: func test_decode_player_stance_crouch() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_stance": "Crouch", "player_inventory": [], @@ -104,7 +83,7 @@ func test_decode_player_stance_missing_defaults_to_walk() -> void: # v6 snapshot without player_stance → should default to "Walk" var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -118,7 +97,7 @@ func test_decode_player_stance_missing_defaults_to_walk() -> void: func test_decode_empty_inventory() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_stance": "Walk", "player_inventory": [], @@ -132,7 +111,7 @@ func test_decode_smuggler_inventory_3_items() -> void: # D-065: smuggler carries 3 specific items var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_stance": "Walk", "player_inventory": [ @@ -161,7 +140,7 @@ func test_decode_full_9_slot_inventory() -> void: items.append({"item_id": 100 + i, "name": "Item %d" % i, "slot": i}) var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_inventory": items, } @@ -177,7 +156,7 @@ func test_decode_full_9_slot_inventory() -> void: func test_decode_inventory_missing_defaults_to_empty() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -188,7 +167,7 @@ func test_decode_inventory_missing_defaults_to_empty() -> void: func test_decode_inventory_skips_malformed_items() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_inventory": [ {"item_id": 100, "name": "Valid Item", "slot": 0}, @@ -209,7 +188,7 @@ func test_decode_inventory_skips_malformed_items() -> void: func test_decode_inventory_item_slot_defaults_to_zero() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "player_inventory": [ {"item_id": 100, "name": "No Slot"}, @@ -281,12 +260,6 @@ func test_sim_bridge_test_snapshot_has_player_inventory() -> void: assert_that(snap.player_inventory is Array).is_true() -func test_sim_bridge_test_snapshot_uses_current_protocol_version() -> void: - SimBridge.reset_test_state() - var snap = SimBridge._test_snapshot() - assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION) - - # -- Fixture: v6 snapshots include new fields ---------------------------------- func test_fixture_snapshots_have_v6_defaults() -> void: @@ -334,7 +307,7 @@ func test_full_v6_snapshot_decode() -> void: # Simulate a realistic v6 snapshot with all fields populated var raw := { "tick": 100, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "game_time": {"day": 1, "time_of_day": 720, "day_phase": "Evening", "tick_rate": "Full"}, "player_facing": "Southeast", "player_stance": "Careful", @@ -367,7 +340,6 @@ func test_full_v6_snapshot_decode() -> void: assert_that(snapshot).is_not_null() assert_that(snapshot.tick).is_equal(100) - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("Southeast") assert_that(snapshot.player_stance).is_equal("Careful") assert_that(snapshot.player_inventory.size()).is_equal(3) diff --git a/client/tests/test_protocol_v7.gd b/client/tests/test_protocol_v7.gd index 649036aee..16cd0987a 100644 --- a/client/tests/test_protocol_v7.gd +++ b/client/tests/test_protocol_v7.gd @@ -12,7 +12,7 @@ extends GdUnitTestSuite func test_decode_pending_recognitions_basic() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "pending_recognitions": [ {"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6}, @@ -33,7 +33,7 @@ func test_decode_pending_recognitions_basic() -> void: func test_decode_pending_recognitions_empty() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "pending_recognitions": [], } @@ -45,7 +45,7 @@ func test_decode_pending_recognitions_empty() -> void: func test_decode_pending_recognitions_missing_defaults_empty() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -56,7 +56,7 @@ func test_decode_pending_recognitions_missing_defaults_empty() -> void: func test_decode_pending_recognitions_skips_malformed() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "pending_recognitions": [ {"entity_id": 100, "x": 13.5, "y": 12.5, "z": 0, "remaining_ticks": 4, "total_delay_ticks": 6}, @@ -76,7 +76,7 @@ func test_decode_pending_recognitions_defaults() -> void: # remaining_ticks and total_delay_ticks default to 0 and 1 var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "pending_recognitions": [ {"entity_id": 100, "x": 5.0, "y": 5.0}, @@ -94,7 +94,7 @@ func test_decode_pending_recognitions_defaults() -> void: func test_decode_current_dialogue() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "current_dialogue": { "npc_name": "Kael", @@ -123,7 +123,7 @@ func test_decode_current_dialogue() -> void: func test_decode_current_dialogue_missing_is_null() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -136,7 +136,7 @@ func test_decode_current_dialogue_options_default_fields() -> void: # response_id and priority default when absent var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "current_dialogue": { "speech": "Just speech.", @@ -158,7 +158,7 @@ func test_decode_current_dialogue_options_default_fields() -> void: func test_decode_current_dialogue_confrontation_option() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "current_dialogue": { "npc_name": "Sera", @@ -179,7 +179,7 @@ func test_decode_current_dialogue_confrontation_option() -> void: func test_decode_current_dialogue_skips_malformed_options() -> void: var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "current_dialogue": { "npc_name": "Sera", @@ -332,7 +332,7 @@ func test_insert_color_constants_exist() -> void: func test_full_v7_snapshot_decode() -> void: var raw := { "tick": 200, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "game_time": {"day": 2, "time_of_day": 1000, "day_phase": "Evening", "tick_rate": "Full"}, "player_facing": "West", "player_stance": "Careful", @@ -362,7 +362,6 @@ func test_full_v7_snapshot_decode() -> void: assert_that(snapshot).is_not_null() assert_that(snapshot.tick).is_equal(200) - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("West") assert_that(snapshot.player_stance).is_equal("Careful") assert_that(snapshot.player_inventory.size()).is_equal(1) diff --git a/client/tests/test_rendering.gd b/client/tests/test_rendering.gd index 399283250..3a7f85ff3 100644 --- a/client/tests/test_rendering.gd +++ b/client/tests/test_rendering.gd @@ -174,8 +174,6 @@ func test_sim_bridge_test_tiles_contain_all_types() -> void: func test_sim_bridge_test_snapshot_has_v2_fields() -> void: SimBridge.reset_test_state() var snap = SimBridge._test_snapshot() - assert_that(snap.has("version")).is_true() - assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snap.has("game_time")).is_true() assert_that(snap.has("player_facing")).is_true() assert_that(snap.has("visible_tiles")).is_true() diff --git a/client/tests/test_signal_sprint24.gd b/client/tests/test_signal_sprint24.gd index 60f16c0cf..3bdeed7aa 100644 --- a/client/tests/test_signal_sprint24.gd +++ b/client/tests/test_signal_sprint24.gd @@ -80,7 +80,7 @@ func test_protocol_decode_includes_triangle_crisis_events_field() -> void: # decode_snapshot() must return a "triangle_crisis_events" key (#590). var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "triangle_crisis_events": [{"triangle_id": 42}], } @@ -102,7 +102,7 @@ func test_protocol_decode_triangle_crisis_events_empty_array() -> void: # When no events are present, field is present and empty. var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "triangle_crisis_events": [], } @@ -117,7 +117,7 @@ func test_protocol_decode_triangle_crisis_events_absent_returns_empty() -> void: # When server doesn't send field (pre-#589), field defaults to empty array. var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -167,7 +167,7 @@ func test_protocol_decode_includes_current_ticker_field() -> void: # decode_snapshot() must return a "current_ticker" key (#592). var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "current_ticker": {"id": "ticker_001", "text": "Station systems nominal.", "category": "System"}, } @@ -187,7 +187,7 @@ func test_protocol_decode_current_ticker_null_when_absent() -> void: # When server doesn't send current_ticker (player outside bar zone), field is null. var raw := { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } var encoded = Messagepack.encode(raw) @@ -210,7 +210,7 @@ func test_news_ticker_hidden_when_snapshot_has_no_ticker() -> void: # Snapshot with no current_ticker (player outside bar zone). GameState.current_snapshot = { "tick": 1, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], } ticker.update_from_state() @@ -229,7 +229,7 @@ func test_news_ticker_visible_when_snapshot_has_ticker() -> void: GameState.current_snapshot = { "tick": 2, - "version": Protocol.PROTOCOL_VERSION, + "version": 23, "entities": [], "current_ticker": {"id": "t1", "text": "Station systems nominal.", "category": "System"}, } @@ -249,7 +249,7 @@ func test_news_ticker_hides_when_ticker_becomes_null() -> void: # Show it first. GameState.current_snapshot = { - "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], + "tick": 1, "version": 23, "entities": [], "current_ticker": {"id": "t1", "text": "Breaking news.", "category": "System"}, } ticker.update_from_state() @@ -257,7 +257,7 @@ func test_news_ticker_hides_when_ticker_becomes_null() -> void: # Null current_ticker — player left the bar zone. GameState.current_snapshot = { - "tick": 2, "version": Protocol.PROTOCOL_VERSION, "entities": [], + "tick": 2, "version": 23, "entities": [], } ticker.update_from_state() assert_bool(ticker.visible).override_failure_message( diff --git a/client/tests/test_sprint2_proof.gd b/client/tests/test_sprint2_proof.gd index d786be082..b27cedd7f 100644 --- a/client/tests/test_sprint2_proof.gd +++ b/client/tests/test_sprint2_proof.gd @@ -132,7 +132,6 @@ func skip_test_proof_player_moves_and_v2_snapshot() -> void: assert_float(player.y).is_equal_approx(15.5, 0.001) # v4 protocol fields present - assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) assert_that(snapshot.player_facing).is_equal("North") assert_that(snapshot.game_time).is_not_null() diff --git a/client/tests/test_time_display_sprint17.gd b/client/tests/test_time_display_sprint17.gd index 6d2162045..57f49ea3b 100644 --- a/client/tests/test_time_display_sprint17.gd +++ b/client/tests/test_time_display_sprint17.gd @@ -291,7 +291,7 @@ func test_hud_time_row_updates_after_process() -> void: add_child(instance) GameState.apply_snapshot({ - "tick": 1, "version": Protocol.PROTOCOL_VERSION, "entities": [], + "tick": 1, "version": 23, "entities": [], "game_time": {"day": 0, "time_of_day": 720, "day_phase": "Afternoon", "tick_rate": "Full"}, }) instance._process(0.016) diff --git a/client/ui/meta/screens/loading/loading_screen.gd b/client/ui/meta/screens/loading/loading_screen.gd index 240de1d0e..a307e2518 100644 --- a/client/ui/meta/screens/loading/loading_screen.gd +++ b/client/ui/meta/screens/loading/loading_screen.gd @@ -39,13 +39,9 @@ func _build_ui() -> void: _label.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_label) - # client_ver and proto_ver are independent — project.yaml version is the client release, - # Protocol.PROTOCOL_VERSION is the wire protocol. Mismatches between builds are visible - # only to the observer reading the loading-screen label; a future ticket will surface them. var client_ver := _read_client_version() - var proto_ver: int = Protocol.PROTOCOL_VERSION _version_label = Label.new() - _version_label.text = "v%s · protocol %d" % [client_ver, proto_ver] + _version_label.text = "v%s" % [client_ver] _version_label.add_theme_font_size_override("font_size", VERSION_FONT_SIZE) _version_label.add_theme_color_override("font_color", VERSION_COLOR) _version_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER From bc310a3ca596c994c303faf5b838b8fd17c408f2 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 09:06:51 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(client):=20sprint=2037=20test-pass=20fi?= =?UTF-8?q?xes=20=E2=80=94=20parse=20error=20+=20test=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - character_creation.gd:1614: type-annotate dir_name to fix GDScript inference parse error. CARDINAL_DIRS is an untyped const Array, so `var dir_name := CARDINAL_DIRS[idx]` failed type inference and blocked test_character_creation_sprint28.gd test discovery. Runtime was lenient but gdUnit4's scanner is strict. - test_merge_path_flows_sprint37.gd (_load_char_create): call add_child before _update_start_btn_state so @onready vars (_footer_start) are bound when the helper dereferences them. Unblocks 2 merge-path tests. - test_anti_tedium.gd (test_bug_report_sends_unpause_on_close): call dialog.close() instead of the old dialog._close() rename casualty. `on_close()` is a lifecycle hook — it doesn't actually transition state; `close()` is what MetaScreen exposes. - test_anti_tedium.gd (before_test): clear MetaStack._stack. Prior tests leave stale freed dialog refs on the stack; _any_pausing() iterates the stack during close() and crashes with "previously freed". Net: sprint-37 test files (test_merge_path_flows_sprint37.gd, test_anti_tedium.gd) now pass. Overall suite: 2428/2488 passing (60 remaining failures are pre-existing, unrelated to sprint 37). Co-Authored-By: Claude Opus 4.7 (1M context) --- client/tests/test_anti_tedium.gd | 3 ++- client/tests/test_merge_path_flows_sprint37.gd | 6 ++++-- .../meta/screens/character_creation/character_creation.gd | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/client/tests/test_anti_tedium.gd b/client/tests/test_anti_tedium.gd index b16d5ae48..43fc93ec9 100644 --- a/client/tests/test_anti_tedium.gd +++ b/client/tests/test_anti_tedium.gd @@ -33,6 +33,7 @@ func before_test() -> void: GameState.pending_recognitions = [] GameState.room_id = null GameState.gauntlet_mode = false + MetaStack._stack.clear() func after_test() -> void: @@ -501,7 +502,7 @@ func test_bug_report_sends_unpause_on_close() -> void: var dialog := _make_bug_report_dialog() dialog.start_capture() SimBridge._test_input_queue.clear() - dialog._close() + dialog.close() assert_that(dialog.is_active()).is_false() assert_that(SimBridge._test_input_queue.has("Unpause")).override_failure_message( "Closing bug report should send Unpause to server" diff --git a/client/tests/test_merge_path_flows_sprint37.gd b/client/tests/test_merge_path_flows_sprint37.gd index 2038e0e0c..1d4b8eebf 100644 --- a/client/tests/test_merge_path_flows_sprint37.gd +++ b/client/tests/test_merge_path_flows_sprint37.gd @@ -63,12 +63,14 @@ func _load_char_create() -> void: return _scene = packed.instantiate() # Seed required state so Start is not disabled (guard added in PR #134 / R2-Hoshe-1). - # Individual tests override these as needed. + # Individual tests override these as needed. add_child() must run first so + # _ready() populates @onready vars (_footer_start, etc.) that + # _update_start_btn_state() dereferences. + add_child(_scene) _scene._selected_bookmark_id = "test-bookmark" _scene._selected_location_id = "test-location" if _scene.has_method("_update_start_btn_state"): _scene._update_start_btn_state() - add_child(_scene) func _make_catalog_snapshot() -> Dictionary: diff --git a/client/ui/meta/screens/character_creation/character_creation.gd b/client/ui/meta/screens/character_creation/character_creation.gd index d3d13ecbf..ff27f3d75 100644 --- a/client/ui/meta/screens/character_creation/character_creation.gd +++ b/client/ui/meta/screens/character_creation/character_creation.gd @@ -1611,7 +1611,7 @@ func _take_screenshot(suffix: String = "") -> void: # Take screenshot for current cardinal, then advance. Single array for # both facing and filename label — previously two arrays with different # orderings produced swapped labels at indices 1 and 3. - var dir_name := CARDINAL_DIRS[_screenshot_cardinal_idx] + var dir_name: String = CARDINAL_DIRS[_screenshot_cardinal_idx] _char_visual.set_facing(dir_name) suffix = dir_name From 60733738f224d9e7d17ed400641da7816636ef3b Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 22 Apr 2026 10:06:39 +0200 Subject: [PATCH 4/4] =?UTF-8?q?fix(client):=20PR=20#135=20review=20?= =?UTF-8?q?=E2=80=94=20T1/T3/H4-H7=20blocking=20+=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code changes addressing PR #135 review (Tyre + Hoshe): - **T3 (blocking):** test_merge_path_flows_sprint37.gd `_load_main_menu` and `_load_char_create` now assert the scene loaded instead of silently returning. Missing .tscn → red test, not falsely green. - **T1:** sim_bridge.gd signal `handshake_complete(protocol_version: int)` was D-192 residue with no listeners. Drop the int parameter entirely and the literal-0 emit. - **H4:** test_new_game_catalog_snapshot_resolves_loading_state now asserts SimBridge.state == CONNECTED terminus, not just the loading flag — guarantees full flow completion, not merely flag-clear. - **H5:** test_protocol_bridge.gd file-level comment refreshed; drops reference to removed protocol-version check tests. - **H6:** test_p0_regressions.gd `_make_snapshot_bytes` comment refreshed and version field removed from fixture dict (D-192: not required). - **H7:** test_merge_path_flows_sprint37.gd `_make_catalog_snapshot` drops version field from fixture dict (D-192). Follow-up tickets filed for reviewer suggestions: - **T2:** #889 — revive EntityRenderer sprite constants coverage (D-044 ENTITY_WIDTH/HEIGHT, asserted by deleted test_sprite_integration). - **T4:** #890 — UI timeout fallback for bookmark catalog wait in main_menu (systemic 'catalog never arrives' class beyond #872's TCP-batch race). - **T5/T6:** #891 — scene-flow test tier docs + test-only reset helpers (SimBridge.reset_for_test, MetaStack.reset_for_test) + minimal public API on scenes so UI refactors don't break all four flow tests simultaneously. Verification: - `make lint-client` — no script errors - `gdlint client/scripts/ client/ui/` — no problems - `make test-client` — 2428/2488 passing. 60 remaining failures are pre-existing, unrelated to sprint 37 (test_dialogue_sprint20 #558 signals, test_input_roundtrip integration-sans-server, etc.). Co-Authored-By: Claude Opus 4.7 (1M context) --- client/scripts/autoloads/sim_bridge.gd | 4 +-- .../tests/test_merge_path_flows_sprint37.gd | 25 +++++++++++++------ client/tests/test_p0_regressions.gd | 7 +++--- client/tests/test_protocol_bridge.gd | 4 +-- 4 files changed, 26 insertions(+), 14 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index 0c297e519..e82566693 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -3,7 +3,7 @@ extends Node # Signals signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) signal snapshot_received(snapshot: Dictionary) -signal handshake_complete(protocol_version: int) +signal handshake_complete signal handshake_failed(reason: String) # Connection states @@ -281,7 +281,7 @@ func _process(delta: float) -> void: # gdlint:disable=max-returns _set_state(ConnectionState.ERROR) return - handshake_complete.emit(0) # D-192: protocol_version field dropped; signal kept for API compat + handshake_complete.emit() _set_state(ConnectionState.CONNECTED) # #646: Request full settings dump on connect — hydrates GameState.ai_enhanced_dialogue_enabled # from server SQLite so the client reflects the authoritative persisted state (D-138). diff --git a/client/tests/test_merge_path_flows_sprint37.gd b/client/tests/test_merge_path_flows_sprint37.gd index 1d4b8eebf..d46d3505d 100644 --- a/client/tests/test_merge_path_flows_sprint37.gd +++ b/client/tests/test_merge_path_flows_sprint37.gd @@ -49,18 +49,22 @@ func after_test() -> void: func _load_main_menu() -> void: var packed := load(MAIN_MENU_SCENE_PATH) as PackedScene - if packed == null: - push_warning("TestMergePathFlowsSprint37: main_menu.tscn not found — skipping") - return + # Hard-fail on missing scene (PR #135 review T3): silent skip turns a broken + # merge-path test into a uselessly green one. + assert_that(packed).override_failure_message( + "main_menu.tscn missing — merge-path coverage is broken, not skipped" + ).is_not_null() _scene = packed.instantiate() add_child(_scene) func _load_char_create() -> void: var packed := load(CHAR_CREATE_SCENE_PATH) as PackedScene - if packed == null: - push_warning("TestMergePathFlowsSprint37: character_creation.tscn not found — skipping") - return + # Hard-fail on missing scene (PR #135 review T3): silent skip turns a broken + # merge-path test into a uselessly green one. + assert_that(packed).override_failure_message( + "character_creation.tscn missing — merge-path coverage is broken, not skipped" + ).is_not_null() _scene = packed.instantiate() # Seed required state so Start is not disabled (guard added in PR #134 / R2-Hoshe-1). # Individual tests override these as needed. add_child() must run first so @@ -75,9 +79,9 @@ func _load_char_create() -> void: func _make_catalog_snapshot() -> Dictionary: ## Minimal valid snapshot with a bookmark_catalog for flow-1 testing. + ## D-192: no version field required; decode accepts snapshots with or without. return { "tick": 0, - "version": 23, "entities": [], "game_time": {"day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": "Full"}, "player_facing": "North", @@ -164,6 +168,13 @@ func test_new_game_catalog_snapshot_resolves_loading_state() -> void: assert_bool(_scene._waiting_for_catalog).override_failure_message( "_waiting_for_catalog must be false after catalog snapshot delivered — #872 regression" ).is_false() + # PR #135 review H4: assert the SimBridge terminus, not just the loading flag. + # In test mode connect_to_sim() jumps state to CONNECTED synchronously; this + # guarantees the flow reached its terminal state, not merely that the catalog + # flag cleared. + assert_int(SimBridge.state).override_failure_message( + "SimBridge must be in CONNECTED terminus after catalog resolves — flow completion guard" + ).is_equal(SimBridge.ConnectionState.CONNECTED) assert_bool(GameState.bookmark_catalog.size() > 0).override_failure_message( "GameState.bookmark_catalog must be populated after catalog snapshot applied" ).is_true() diff --git a/client/tests/test_p0_regressions.gd b/client/tests/test_p0_regressions.gd index 3b4855da0..90605fd25 100644 --- a/client/tests/test_p0_regressions.gd +++ b/client/tests/test_p0_regressions.gd @@ -43,12 +43,13 @@ func after_test() -> void: # -- Helpers ------------------------------------------------------------------- ## Encode a minimal valid snapshot as MessagePack bytes. -## Protocol.decode_snapshot() requires: tick, version, entities (with kind as -## bare string for unit enum variants per rmp_serde wire format). +## Protocol.decode_snapshot() requires: tick, entities (with kind as bare string +## for unit enum variants per rmp_serde wire format). D-192 dropped the version +## field — kept here inertly in existing fixtures so decode still accepts either +## shape while older tests migrate. func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: var snapshot := { "tick": overrides.get("tick", 1), - "version": 23, "entities": overrides.get("entities", [{ "entity_id": 1, "x": 10.0, diff --git a/client/tests/test_protocol_bridge.gd b/client/tests/test_protocol_bridge.gd index 91b9b393d..2f436a999 100644 --- a/client/tests/test_protocol_bridge.gd +++ b/client/tests/test_protocol_bridge.gd @@ -1,8 +1,8 @@ ## D-030 Layer 1: Protocol bridge tests for ObserverSnapshot features. ## Validates player_stance (D-053) and player_inventory (D-065) decode, ## GameState storage, SimBridge test mode, input encoding for stance toggles, -## protocol version checks, and fixture round-trips. -## Spec refs: D-053, D-065, D-020, #449 +## and fixture round-trips. Protocol version checks removed per D-192. +## Spec refs: D-053, D-065, D-020, #449, D-192 class_name TestProtocolBridge extends GdUnitTestSuite