## Client P0 regression tests: guards for Bug #5 (monologue lost) and Bug #2 (camera drift). ## 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. ## ## 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, version, entities (with kind as ## bare string for unit enum variants per rmp_serde wire format). func _make_snapshot_bytes(overrides: Dictionary = {}) -> PackedByteArray: var snapshot := { "tick": overrides.get("tick", 1), "version": Protocol.PROTOCOL_VERSION, "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"] 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)