Files
settled-reach/client/tests/test_p0_regressions.gd
T
jpmschweitzerandClaude Opus 4.6 4eb54a6f7a fix(client): resolve all gdlint warnings — zero warnings policy
Fix 354 gdlint warnings across 65 files: 194 class-definitions-order
(reorder declarations), 138 max-line-length (split long lines),
22 code issues (unused args, no-else-return, naming). Update .gdlintrc
to exclude addons/ and raise max-public-methods for test files.
No logic changes — declaration order, whitespace, and naming only.

Ticket: #783

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 09:59:56 +02:00

232 lines
7.8 KiB
GDScript

## 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)