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>
232 lines
7.8 KiB
GDScript
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)
|