## 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 extends GdUnitTestSuite const MAIN_SCENE = preload("res://scenes/main.tscn") var _instance: Node = null func before_test() -> void: SimBridge.reset_test_state() SimBridge._last_snapshot = null GameState.current_tick = 0 GameState.player_position = Vector2.ZERO GameState.visible_entities = [] GameState.visible_tiles = [] GameState.visible_positions = {} GameState.current_monologue = null GameState.current_dialogue = null GameState.game_time = {} GameState.pending_recognitions = [] func after_test() -> void: if _instance and is_instance_valid(_instance): _instance.queue_free() _instance = null # -- Helpers ------------------------------------------------------------------- ## Encode a minimal valid snapshot as MessagePack bytes. ## 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), "entities": overrides.get("entities", [{ "entity_id": 1, "x": 10.0, "y": 10.0, "z": 0, "kind": "Player", "visibility": "Forward", }]), "game_time": { "day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": overrides.get("tick_rate", "Full"), }, "player_facing": "North", "player_stance": "Walk", "player_inventory": [], "visible_tiles": [], "nearby_interactions": [], "pending_recognitions": [], } # Only include current_monologue if explicitly provided (null omitted) if overrides.has("current_monologue"): 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 # -- Bug #5: Monologue carry-forward ------------------------------------------ # The server sends one-shot monologue in a snapshot. If the server sends another # snapshot (without monologue) before the client polls, the old snapshot is # overwritten. The carry-forward logic in receive_bytes() must preserve the # monologue from the overwritten snapshot. func test_monologue_not_lost_on_snapshot_overwrite() -> void: # Snapshot 1: server sends monologue var bytes_with_mono := _make_snapshot_bytes({ "tick": 1, "current_monologue": { "id": "test_mono_001", "text": "Something about this manifest doesn't add up.", "duration_seconds": 5.0, }, }) SimBridge.receive_bytes(bytes_with_mono) # Snapshot 2: server sends next tick WITHOUT monologue (overwrite scenario) var bytes_without_mono := _make_snapshot_bytes({ "tick": 2, }) SimBridge.receive_bytes(bytes_without_mono) # Assert: _last_snapshot must still carry the monologue from snapshot 1. # This is the carry-forward fix for Bug #5. assert_that(SimBridge._last_snapshot).is_not_null() var mono: Variant = SimBridge._last_snapshot.get("current_monologue") assert_that(mono).is_not_null() assert_that(mono is Dictionary).is_true() assert_that(mono.get("text")).is_equal("Something about this manifest doesn't add up.") func test_monologue_not_duplicated_after_consumption() -> void: # After the client consumes a carried-forward monologue, the next snapshot # without monologue should NOT carry forward again (it was already consumed). var bytes_with_mono := _make_snapshot_bytes({ "tick": 1, "current_monologue": { "id": "test_mono_002", "text": "The corridors feel different at night.", "duration_seconds": 3.0, }, }) SimBridge.receive_bytes(bytes_with_mono) # Client polls and consumes the monologue. # In live mode, poll_snapshot() returns _last_snapshot and sets it to null. # In test mode, poll_snapshot() returns _test_snapshot() instead, so we # simulate the live-mode consumption path directly. var snapshot = SimBridge._last_snapshot SimBridge._last_snapshot = null assert_that(snapshot).is_not_null() GameState.apply_snapshot(snapshot) # apply_snapshot sets current_monologue, then main._consume_monologue() clears it. # Simulate consumption: GameState.current_monologue = null # Next snapshot arrives without monologue — _last_snapshot was null after poll, # so no carry-forward should happen. var bytes_next := _make_snapshot_bytes({"tick": 2}) SimBridge.receive_bytes(bytes_next) var mono: Variant = SimBridge._last_snapshot.get("current_monologue") assert_that(mono).is_null() func test_monologue_carry_forward_preserves_newest() -> void: # If two snapshots both have monologue, the second one wins (no accumulation). var bytes_mono1 := _make_snapshot_bytes({ "tick": 1, "current_monologue": { "id": "first", "text": "First thought.", "duration_seconds": 3.0, }, }) SimBridge.receive_bytes(bytes_mono1) var bytes_mono2 := _make_snapshot_bytes({ "tick": 2, "current_monologue": { "id": "second", "text": "Second thought.", "duration_seconds": 3.0, }, }) SimBridge.receive_bytes(bytes_mono2) # Second monologue should win — latest snapshot takes precedence. var mono: Variant = SimBridge._last_snapshot.get("current_monologue") assert_that(mono).is_not_null() assert_that(mono.get("text")).is_equal("Second thought.") # -- Bug #2: Camera static during pause --------------------------------------- # During pause, the server sends snapshots with unchanged player position. # The camera must remain at the same position — no drift, no snap to origin. func test_camera_static_during_pause() -> void: # 1. Instantiate main scene — camera anchors at test mode player position var scene := MAIN_SCENE _instance = scene.instantiate() auto_free(_instance) add_child(_instance) var camera: Camera2D = _instance.get_node("Camera2D") var expected_pos := Vector2(10, 10) * Constants.TILE_SIZE # (320, 320) # 2. Verify camera is anchored at expected position assert_that(camera.global_position).is_equal(expected_pos) assert_that(_instance._camera_anchored).is_true() # 3. Set game state to Paused GameState.game_time = { "day": 0, "time_of_day": 0, "day_phase": "Morning", "tick_rate": "Paused", } # 4. Process a frame — in test mode, poll_snapshot returns same position # (no movement inputs queued), simulating server pause behavior _instance._process(0.016) # 5. Camera must remain at the same position (no drift) assert_that(camera.global_position).is_equal(expected_pos) func test_camera_anchored_after_pause_unpause() -> void: # Verify camera stays properly anchored through a pause → unpause cycle. var scene := MAIN_SCENE _instance = scene.instantiate() auto_free(_instance) add_child(_instance) var camera: Camera2D = _instance.get_node("Camera2D") var expected_pos := Vector2(10, 10) * Constants.TILE_SIZE # Process several frames with Paused tick rate GameState.game_time.tick_rate = "Paused" for i in 5: _instance._process(0.016) assert_that(camera.global_position).is_equal(expected_pos) assert_that(_instance._camera_anchored).is_true() # Unpause and process — camera should track the (potentially moved) player GameState.game_time.tick_rate = "Full" _instance._process(0.016) # 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()