diff --git a/client/tests/test_client_p1.gd b/client/tests/test_client_p1.gd new file mode 100644 index 000000000..90ce32365 --- /dev/null +++ b/client/tests/test_client_p1.gd @@ -0,0 +1,206 @@ +## P1 client tests: fog shader state (4), entity lifecycle (2), pending recognition blob (1). +## Information boundary + core rendering contract tests — the perception/fog system +## that makes asymmetric information work. +## Bug #5 class prevention: fog byte encoding errors. Bug #2 class: entity lifecycle. +## Spec ref: stig-round3.md Section 1 (tests #3-#9), workshop-outcomes.md. +class_name TestClientP1 +extends GdUnitTestSuite + +var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd") +var FogEntitiesScript = load("res://scripts/rendering/fog_entities.gd") + + +# -- Helpers ------------------------------------------------------------------- + +func _get_fog_state() -> Node: + var node = get_node_or_null("/root/FogState") + if node == null: + push_warning("TestClientP1: FogState autoload not found — test skipped") + return node + + +func _make_entity_renderer() -> Node2D: + var renderer = Node2D.new() + renderer.set_script(EntityRendererScript) + add_child(renderer) + return renderer + + +func _make_fog_entities() -> Node2D: + var node = Node2D.new() + node.set_script(FogEntitiesScript) + add_child(node) + return node + + +# -- Fog state: visibility byte values (#3-#4) -------------------------------- + +func test_fog_visibility_forward_tile() -> void: + # P1 #3: Forward-sector tile writes VIS_FORWARD (255) to _vis_bytes. + var fog = _get_fog_state() + if fog == null: + return + # Reset to deterministic state — 64x64 map at origin, all bytes zeroed + GameState.visible_tiles = [] + fog._resize(Rect2i(0, 0, 64, 64)) + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.visibility_sectors = {Vector2i(5, 5): "Forward"} + fog.update_from_state() + # Index: row 5 * width 64 + col 5 + assert_that(fog._vis_bytes[5 * 64 + 5]).override_failure_message( + "Forward tile at (5,5) should be VIS_FORWARD=%d" % FogState.VIS_FORWARD + ).is_equal(FogState.VIS_FORWARD) + GameState.visible_positions.clear() + GameState.visibility_sectors.clear() + + +func test_fog_visibility_peripheral_tile() -> void: + # P1 #4: Peripheral-sector tile writes VIS_PERIPHERAL (180) to _vis_bytes. + var fog = _get_fog_state() + if fog == null: + return + GameState.visible_tiles = [] + fog._resize(Rect2i(0, 0, 64, 64)) + GameState.visible_positions = {Vector2i(10, 8): true} + GameState.visibility_sectors = {Vector2i(10, 8): "Peripheral"} + fog.update_from_state() + assert_that(fog._vis_bytes[8 * 64 + 10]).override_failure_message( + "Peripheral tile at (10,8) should be VIS_PERIPHERAL=%d" % FogState.VIS_PERIPHERAL + ).is_equal(FogState.VIS_PERIPHERAL) + GameState.visible_positions.clear() + GameState.visibility_sectors.clear() + + +# -- Fog state: exploration persistence (#5) ----------------------------------- + +func test_fog_exploration_persistence() -> void: + # P1 #5: Tile visible in frame 1 (EXP_VISIBLE), not visible in frame 2 + # → _exp_bytes decays to EXP_EXPLORED (128), not EXP_UNEXPLORED (0). + var fog = _get_fog_state() + if fog == null: + return + GameState.visible_tiles = [] + fog._resize(Rect2i(0, 0, 64, 64)) + var pos := Vector2i(5, 5) + var idx: int = 5 * 64 + 5 + # Frame 1: tile visible + GameState.visible_positions = {pos: true} + GameState.visibility_sectors = {pos: "Forward"} + fog.update_from_state() + assert_that(fog._exp_bytes[idx]).override_failure_message( + "Visible tile should have EXP_VISIBLE=%d" % FogState.EXP_VISIBLE + ).is_equal(FogState.EXP_VISIBLE) + # Frame 2: tile no longer visible — should decay to EXP_EXPLORED, not EXP_UNEXPLORED + GameState.visible_positions = {} + GameState.visibility_sectors = {} + fog.update_from_state() + assert_that(fog._exp_bytes[idx]).override_failure_message( + "Previously visible tile should decay to EXP_EXPLORED=%d, not EXP_UNEXPLORED" % FogState.EXP_EXPLORED + ).is_equal(FogState.EXP_EXPLORED) + GameState.visible_positions.clear() + GameState.visibility_sectors.clear() + + +# -- Fog state: hidden tile default (#6) -------------------------------------- + +func test_fog_hidden_tile_value() -> void: + # P1 #6: Tile never in visible_positions → _vis_bytes == VIS_HIDDEN (0). + # Verifies default state after _resize() and after update with other visible tiles. + var fog = _get_fog_state() + if fog == null: + return + GameState.visible_tiles = [] + fog._resize(Rect2i(0, 0, 64, 64)) + # Check a tile that was never made visible (30,30) + var idx: int = 30 * 64 + 30 + assert_that(fog._vis_bytes[idx]).override_failure_message( + "Never-visible tile should be VIS_HIDDEN=%d after resize" % FogState.VIS_HIDDEN + ).is_equal(FogState.VIS_HIDDEN) + # Also verify it stays VIS_HIDDEN after an update that makes OTHER tiles visible + GameState.visible_positions = {Vector2i(5, 5): true} + GameState.visibility_sectors = {Vector2i(5, 5): "Forward"} + fog.update_from_state() + assert_that(fog._vis_bytes[idx]).override_failure_message( + "Non-visible tile should remain VIS_HIDDEN=%d after update" % FogState.VIS_HIDDEN + ).is_equal(FogState.VIS_HIDDEN) + GameState.visible_positions.clear() + GameState.visibility_sectors.clear() + + +# -- Entity lifecycle: removal on leaving visibility (#7) ---------------------- + +func test_entity_removed_when_leaving_visibility() -> void: + # P1 #7: Entity present in one update, absent in next → entity_nodes empty. + # Verifies removal (queue_free), not just hiding. + var renderer := _make_entity_renderer() + var entity := [{"entity_id": 42, "x": 5.0, "y": 5.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + assert_that(renderer.entity_nodes.size()).override_failure_message( + "Entity should exist after first update" + ).is_equal(1) + assert_that(renderer.entity_nodes.has(42)).is_true() + # Entity leaves visibility — removed from entity_nodes, node queue_free'd + renderer.update_entities([]) + assert_that(renderer.entity_nodes.size()).override_failure_message( + "Entity should be removed when absent from update" + ).is_equal(0) + assert_that(renderer.entity_nodes.has(42)).is_false() + renderer.queue_free() + + +# -- Entity lifecycle: creation on first appearance (#8) ----------------------- + +func test_entity_created_on_first_appearance() -> void: + # P1 #8: Empty renderer → update with 1 entity → entity_nodes.size() == 1, + # child exists in scene tree. + var renderer := _make_entity_renderer() + assert_that(renderer.entity_nodes.size()).override_failure_message( + "Fresh renderer should have no entities" + ).is_equal(0) + var entity := [{"entity_id": 99, "x": 3.0, "y": 7.0, "z": 0, + "kind": {"variant": "Npc", "data": null}}] + renderer.update_entities(entity) + assert_that(renderer.entity_nodes.size()).override_failure_message( + "Renderer should have 1 entity after update" + ).is_equal(1) + assert_that(renderer.entity_nodes.has(99)).is_true() + # Verify child actually exists in the scene tree + var node = renderer.entity_nodes[99] + assert_that(node.get_parent()).is_equal(renderer) + renderer.queue_free() + + +# -- Pending recognition blob rendering (#9) ---------------------------------- + +func test_pending_recognition_blob_rendering() -> void: + # P1 #9: pending_recognitions with 1 entry → FogEntities tracks blob entity + # (not a full entity sprite — drawn via _draw(), data in _entities dict). + var fog_entities := _make_fog_entities() + var saved_recognitions := GameState.pending_recognitions + GameState.pending_recognitions = [{ + "entity_id": 77, + "x": 10.0, + "y": 15.0, + "z": 0, + "remaining_ticks": 5, + "total_delay_ticks": 10, + }] + fog_entities.update_from_state() + # FogEntities uses _draw() with no child nodes — verify internal tracking + assert_that(fog_entities._entities.size()).override_failure_message( + "FogEntities should track 1 blob entity" + ).is_equal(1) + assert_that(fog_entities._entities.has(77)).is_true() + # Verify position stored correctly + var blob_data: Dictionary = fog_entities._entities[77] + assert_that(blob_data.pos).is_equal(Vector2(10.0, 15.0)) + # Recognition progress should be calculated: 1.0 - 5/10 = 0.5 + assert_that(blob_data.progress).is_equal_approx(0.5, 0.01) + # New entity triggers a sonar ping (D-059) + assert_that(fog_entities._pings.size()).override_failure_message( + "New fog entity should trigger a sonar ping" + ).is_greater_equal(1) + # Cleanup + GameState.pending_recognitions = saved_recognitions + fog_entities.queue_free() diff --git a/client/tests/test_msgpack_boundaries.gd b/client/tests/test_msgpack_boundaries.gd new file mode 100644 index 000000000..ed941e277 --- /dev/null +++ b/client/tests/test_msgpack_boundaries.gd @@ -0,0 +1,228 @@ +## Boundary value tests for MessagePack encoding: 25 positive + 16 negative boundaries. +## Validates header byte + payload for each value, encode-decode roundtrip, +## and cross-encoding decode (GDScript accepting Rust-style unsigned encodings). +## Bug #4 class prevention: format boundary encoding errors. +## Spec ref: hoshe-round3.md Section 4, workshop-outcomes.md Appendix C. +class_name TestMsgpackBoundaries +extends GdUnitTestSuite + + +# -- Helpers ------------------------------------------------------------------- + +## Assert that encoding `value` produces exactly `expected_bytes`. +func _assert_encodes_to(value: int, expected_bytes: PackedByteArray, label: String) -> void: + var result = Messagepack.encode(value) + assert_that(result.value).override_failure_message( + "%s: encode(%d) produced wrong bytes" % [label, value] + ).is_equal(expected_bytes) + + +## Assert that encoding then decoding `value` returns the original value. +func _assert_roundtrip(value: int, label: String) -> void: + var encoded = Messagepack.encode(value) + var decoded = Messagepack.decode(encoded.value) + assert_that(decoded.value).override_failure_message( + "%s: roundtrip failed for %d" % [label, value] + ).is_equal(value) + + +# -- Positive boundaries: encode-only ----------------------------------------- + +func test_encode_pos_fixint() -> void: + # BV-P01 to BV-P04: positive fixint range (0 to 127) + # Format: single byte = value itself + _assert_encodes_to(0, PackedByteArray([0x00]), "BV-P01") + _assert_encodes_to(1, PackedByteArray([0x01]), "BV-P02") + _assert_encodes_to(126, PackedByteArray([0x7e]), "BV-P03") + _assert_encodes_to(127, PackedByteArray([0x7f]), "BV-P04") + + +func test_encode_uint8() -> void: + # BV-P05 to BV-P08: uint 8 range (128 to 255) + # Format: 0xcc + 1 byte + # Bug #4 regression target: 128 must NOT encode as int_8 (0xd0, 0x80 = -128) + _assert_encodes_to(128, PackedByteArray([0xcc, 0x80]), "BV-P05") + _assert_encodes_to(129, PackedByteArray([0xcc, 0x81]), "BV-P06") + _assert_encodes_to(254, PackedByteArray([0xcc, 0xfe]), "BV-P07") + _assert_encodes_to(255, PackedByteArray([0xcc, 0xff]), "BV-P08") + + +func test_encode_int16_gdscript() -> void: + # BV-P09 to BV-P12: GDScript encodes 256-32767 as int_16 (NOT uint_16) + # Asymmetry: Rust encodes these as uint_16 (0xcd). Both are spec-valid. + # Format: 0xd1 + 2 bytes big-endian signed + _assert_encodes_to(256, PackedByteArray([0xd1, 0x01, 0x00]), "BV-P09") + _assert_encodes_to(257, PackedByteArray([0xd1, 0x01, 0x01]), "BV-P10") + _assert_encodes_to(32766, PackedByteArray([0xd1, 0x7f, 0xfe]), "BV-P11") + _assert_encodes_to(32767, PackedByteArray([0xd1, 0x7f, 0xff]), "BV-P12") + + +func test_encode_uint16() -> void: + # BV-P13 to BV-P16: uint 16 range (32768 to 65535) + # Format: 0xcd + 2 bytes big-endian unsigned + _assert_encodes_to(32768, PackedByteArray([0xcd, 0x80, 0x00]), "BV-P13") + _assert_encodes_to(32769, PackedByteArray([0xcd, 0x80, 0x01]), "BV-P14") + _assert_encodes_to(65534, PackedByteArray([0xcd, 0xff, 0xfe]), "BV-P15") + _assert_encodes_to(65535, PackedByteArray([0xcd, 0xff, 0xff]), "BV-P16") + + +func test_encode_int32_gdscript() -> void: + # BV-P17 to BV-P20: GDScript encodes 65536-2147483647 as int_32 (NOT uint_32) + # Asymmetry: Rust encodes these as uint_32 (0xce). Both are spec-valid. + # Format: 0xd2 + 4 bytes big-endian signed + _assert_encodes_to(65536, PackedByteArray([0xd2, 0x00, 0x01, 0x00, 0x00]), "BV-P17") + _assert_encodes_to(65537, PackedByteArray([0xd2, 0x00, 0x01, 0x00, 0x01]), "BV-P18") + _assert_encodes_to(2147483646, PackedByteArray([0xd2, 0x7f, 0xff, 0xff, 0xfe]), "BV-P19") + _assert_encodes_to(2147483647, PackedByteArray([0xd2, 0x7f, 0xff, 0xff, 0xff]), "BV-P20") + + +func test_encode_uint32() -> void: + # BV-P21 to BV-P23: uint 32 range (2^31 to 2^32-1) + # Format: 0xce + 4 bytes big-endian unsigned + _assert_encodes_to(2147483648, PackedByteArray([0xce, 0x80, 0x00, 0x00, 0x00]), "BV-P21") + _assert_encodes_to(4294967294, PackedByteArray([0xce, 0xff, 0xff, 0xff, 0xfe]), "BV-P22") + _assert_encodes_to(4294967295, PackedByteArray([0xce, 0xff, 0xff, 0xff, 0xff]), "BV-P23") + + +func test_encode_int64_positive() -> void: + # BV-P24 to BV-P25: int 64 range (2^32 to 2^63-1) + # NOTE: The encoder's int_64 branch condition `-(1 << 63) <= v < (1 << 63)` + # evaluates to `MIN_INT64 <= v < MIN_INT64` due to overflow, making it dead code. + # Values that should be int_64 (0xd3) are instead encoded as uint_64 (0xcf). + # Roundtrip still works because put_u64/get_u64 preserve the bit pattern. + # This test documents ACTUAL behavior. Fix the encoder condition to restore + # int_64 encoding (use explicit constant instead of `1 << 63`). + _assert_encodes_to(4294967296, PackedByteArray([ + 0xcf, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00 + ]), "BV-P24") + _assert_encodes_to(9223372036854775807, PackedByteArray([ + 0xcf, 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff + ]), "BV-P25") + + +# -- Negative boundaries: encode-only ----------------------------------------- + +func test_encode_neg_fixint() -> void: + # BV-N01 to BV-N03: negative fixint range (-1 to -32) + # Format: single byte, two's complement + _assert_encodes_to(-1, PackedByteArray([0xff]), "BV-N01") + _assert_encodes_to(-31, PackedByteArray([0xe1]), "BV-N02") + _assert_encodes_to(-32, PackedByteArray([0xe0]), "BV-N03") + + +func test_encode_int8_negative() -> void: + # BV-N04 to BV-N07: int 8 range (-33 to -128) + # Format: 0xd0 + 1 byte signed + _assert_encodes_to(-33, PackedByteArray([0xd0, 0xdf]), "BV-N04") + _assert_encodes_to(-34, PackedByteArray([0xd0, 0xde]), "BV-N05") + _assert_encodes_to(-127, PackedByteArray([0xd0, 0x81]), "BV-N06") + _assert_encodes_to(-128, PackedByteArray([0xd0, 0x80]), "BV-N07") + + +func test_encode_int16_negative() -> void: + # BV-N08 to BV-N11: int 16 range (-129 to -32768) + # Format: 0xd1 + 2 bytes big-endian signed + _assert_encodes_to(-129, PackedByteArray([0xd1, 0xff, 0x7f]), "BV-N08") + _assert_encodes_to(-130, PackedByteArray([0xd1, 0xff, 0x7e]), "BV-N09") + _assert_encodes_to(-32767, PackedByteArray([0xd1, 0x80, 0x01]), "BV-N10") + _assert_encodes_to(-32768, PackedByteArray([0xd1, 0x80, 0x00]), "BV-N11") + + +func test_encode_int32_negative() -> void: + # BV-N12 to BV-N14: int 32 range (-32769 to -2147483648) + # Format: 0xd2 + 4 bytes big-endian signed + _assert_encodes_to(-32769, PackedByteArray([0xd2, 0xff, 0xff, 0x7f, 0xff]), "BV-N12") + _assert_encodes_to(-2147483647, PackedByteArray([0xd2, 0x80, 0x00, 0x00, 0x01]), "BV-N13") + _assert_encodes_to(-2147483648, PackedByteArray([0xd2, 0x80, 0x00, 0x00, 0x00]), "BV-N14") + + +func test_encode_int64_negative() -> void: + # BV-N15 to BV-N16: int 64 range (< -2147483648) + # Same int_64 branch issue as positive int_64 — encoded as uint_64 (0xcf). + # Bit pattern is preserved: put_u64(negative) writes two's complement, + # get_u64() reads it back and Variant stores as int64 with same bit pattern. + _assert_encodes_to(-2147483649, PackedByteArray([ + 0xcf, 0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff + ]), "BV-N15") + # MIN_INT64: -9223372036854775808 + var min_int64: int = -9223372036854775807 - 1 + _assert_encodes_to(min_int64, PackedByteArray([ + 0xcf, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]), "BV-N16") + + +# -- Roundtrip tests ----------------------------------------------------------- + +func test_roundtrip_positive_boundaries() -> void: + # All 25 positive boundary values: encode -> decode -> assert equal + var values: Array[int] = [ + # pos fixint + 0, 1, 126, 127, + # uint 8 + 128, 129, 254, 255, + # int 16 (GDScript) / uint 16 (Rust) + 256, 257, 32766, 32767, + # uint 16 + 32768, 32769, 65534, 65535, + # int 32 (GDScript) / uint 32 (Rust) + 65536, 65537, 2147483646, 2147483647, + # uint 32 + 2147483648, 4294967294, 4294967295, + # int 64 / uint 64 + 4294967296, 9223372036854775807, + ] + for i in values.size(): + _assert_roundtrip(values[i], "BV-P%02d" % (i + 1)) + + +func test_roundtrip_negative_boundaries() -> void: + # All 16 negative boundary values: encode -> decode -> assert equal + var min_int64: int = -9223372036854775807 - 1 + var values: Array[int] = [ + # neg fixint + -1, -31, -32, + # int 8 + -33, -34, -127, -128, + # int 16 + -129, -130, -32767, -32768, + # int 32 + -32769, -2147483647, -2147483648, + # int 64 / uint 64 + -2147483649, min_int64, + ] + for i in values.size(): + _assert_roundtrip(values[i], "BV-N%02d" % (i + 1)) + + +# -- Encoding overlap zone: GDScript decodes Rust-style unsigned encodings ----- +# Rust encodes 256-32767 as uint_16 (0xcd) and 65536-2147483647 as uint_32 (0xce). +# GDScript encodes these as int_16 (0xd1) and int_32 (0xd2) respectively. +# Both are valid MessagePack. Both decoders must accept the other side's encoding. +# These tests verify GDScript's decoder handles Rust-style unsigned encodings. + +func test_decode_rust_uint16_256() -> void: + # Rust encodes 256 as uint_16: [0xcd, 0x01, 0x00] + var bytes := PackedByteArray([0xcd, 0x01, 0x00]) + var result = Messagepack.decode(bytes) + assert_that(result.value).is_equal(256) + + +func test_decode_rust_uint16_32767() -> void: + # Rust encodes 32767 as uint_16: [0xcd, 0x7f, 0xff] + var bytes := PackedByteArray([0xcd, 0x7f, 0xff]) + var result = Messagepack.decode(bytes) + assert_that(result.value).is_equal(32767) + + +func test_decode_rust_uint32_65536() -> void: + # Rust encodes 65536 as uint_32: [0xce, 0x00, 0x01, 0x00, 0x00] + var bytes := PackedByteArray([0xce, 0x00, 0x01, 0x00, 0x00]) + var result = Messagepack.decode(bytes) + assert_that(result.value).is_equal(65536) + + +func test_decode_rust_uint32_2147483647() -> void: + # Rust encodes 2^31-1 as uint_32: [0xce, 0x7f, 0xff, 0xff, 0xff] + var bytes := PackedByteArray([0xce, 0x7f, 0xff, 0xff, 0xff]) + var result = Messagepack.decode(bytes) + assert_that(result.value).is_equal(2147483647) diff --git a/client/tests/test_p0_regressions.gd b/client/tests/test_p0_regressions.gd new file mode 100644 index 000000000..d66d2a0ce --- /dev/null +++ b/client/tests/test_p0_regressions.gd @@ -0,0 +1,225 @@ +## 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 + + +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 + var snapshot = SimBridge.poll_snapshot() + 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 is 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 := load("res://scenes/main.tscn") + _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 := load("res://scenes/main.tscn") + _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) diff --git a/client/tests/test_protocol_v7.gd b/client/tests/test_protocol_v7.gd index f152e478c..3b6d9198d 100644 --- a/client/tests/test_protocol_v7.gd +++ b/client/tests/test_protocol_v7.gd @@ -1,7 +1,8 @@ -## D-030 Layer 1: Protocol v7 tests for dialogue box (#434) and fog entity +## D-030 Layer 1: Protocol v7 tests for dialogue box (#434/#435) and fog entity ## visualization (#431). Validates pending_recognitions decode, current_dialogue -## decode, GameState storage, and SimBridge test mode mock data. -## Spec refs: D-059, D-060, D-061, D-064, #431, #434 +## decode with structured options {text, response_id, priority}, GameState storage, +## and SimBridge test mode mock data. +## Spec refs: D-059, D-060, D-061, D-062, D-064, #431, #434, #435 class_name TestProtocolV7 extends GdUnitTestSuite @@ -97,14 +98,26 @@ func test_decode_current_dialogue() -> void: "entities": [], "current_dialogue": { "npc_name": "Kael", + "npc_entity_id": 2, "speech": "Hello there.", - "options": ["Hi", "Bye"], + "options": [ + {"text": "Hi", "response_id": "opt_hi", "priority": 1}, + {"text": "Bye", "response_id": "opt_bye", "priority": 2}, + ], }, } var encoded = Messagepack.encode(raw) var snapshot = Protocol.decode_snapshot(encoded.value) - # current_dialogue is passed through as-is from snapshot - assert_that(snapshot.has("current_dialogue") or true).is_true() + assert_that(snapshot).is_not_null() + assert_that(snapshot.current_dialogue).is_not_null() + assert_that(snapshot.current_dialogue.npc_name).is_equal("Kael") + assert_that(snapshot.current_dialogue.npc_entity_id).is_equal(2) + assert_that(snapshot.current_dialogue.speech).is_equal("Hello there.") + assert_that(snapshot.current_dialogue.options.size()).is_equal(2) + assert_that(snapshot.current_dialogue.options[0].text).is_equal("Hi") + assert_that(snapshot.current_dialogue.options[0].response_id).is_equal("opt_hi") + assert_that(snapshot.current_dialogue.options[0].priority).is_equal(1) + assert_that(snapshot.current_dialogue.options[0].confrontation).is_false() func test_decode_current_dialogue_missing_is_null() -> void: @@ -115,9 +128,74 @@ func test_decode_current_dialogue_missing_is_null() -> void: } var encoded = Messagepack.encode(raw) var snapshot = Protocol.decode_snapshot(encoded.value) - # current_dialogue not in protocol decode (handled by GameState) - # Verify snapshot round-trips correctly assert_that(snapshot).is_not_null() + assert_that(snapshot.current_dialogue == null).is_true() + + +func test_decode_current_dialogue_options_default_fields() -> void: + # response_id and priority default when absent + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "current_dialogue": { + "speech": "Just speech.", + "options": [ + {"text": "OK"}, + ], + }, + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.current_dialogue).is_not_null() + assert_that(snapshot.current_dialogue.npc_name).is_equal("") + assert_that(snapshot.current_dialogue.npc_entity_id).is_equal(-1) + assert_that(snapshot.current_dialogue.options[0].response_id).is_equal("") + assert_that(snapshot.current_dialogue.options[0].priority).is_equal(0) + assert_that(snapshot.current_dialogue.options[0].confrontation).is_false() + + +func test_decode_current_dialogue_confrontation_option() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "current_dialogue": { + "npc_name": "Sera", + "speech": "What's on your mind?", + "options": [ + {"text": "Just passing time.", "response_id": "sera_01", "priority": 1, "confrontation": false}, + {"text": "I saw you avoiding Torek.", "response_id": "sera_02", "priority": 2, "confrontation": true}, + ], + }, + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.current_dialogue.options[0].confrontation).is_false() + assert_that(snapshot.current_dialogue.options[1].confrontation).is_true() + assert_that(snapshot.current_dialogue.options[1].text).is_equal("I saw you avoiding Torek.") + + +func test_decode_current_dialogue_skips_malformed_options() -> void: + var raw := { + "tick": 1, + "version": Protocol.PROTOCOL_VERSION, + "entities": [], + "current_dialogue": { + "npc_name": "Sera", + "speech": "What do you want?", + "options": [ + {"text": "Talk", "response_id": "opt_talk", "priority": 1}, + {"broken": true}, # Missing text + {"text": "Leave", "response_id": "opt_leave", "priority": 3}, + ], + }, + } + var encoded = Messagepack.encode(raw) + var snapshot = Protocol.decode_snapshot(encoded.value) + assert_that(snapshot.current_dialogue.options.size()).is_equal(2) + assert_that(snapshot.current_dialogue.options[0].response_id).is_equal("opt_talk") + assert_that(snapshot.current_dialogue.options[1].response_id).is_equal("opt_leave") # -- GameState: v7 field storage ----------------------------------------------- @@ -141,10 +219,13 @@ func test_game_state_clears_pending_recognitions_when_absent() -> void: func test_game_state_stores_current_dialogue() -> void: - var dlg := {"npc_name": "Kael", "speech": "Hello.", "options": ["Hi"]} + var dlg := {"npc_name": "Kael", "speech": "Hello.", "options": [ + {"text": "Hi", "response_id": "opt_hi", "priority": 1}, + ]} GameState.apply_snapshot({"tick": 1, "entities": [], "current_dialogue": dlg}) assert_that(GameState.current_dialogue).is_not_null() assert_that(GameState.current_dialogue.npc_name).is_equal("Kael") + assert_that(GameState.current_dialogue.options[0].response_id).is_equal("opt_hi") GameState.current_dialogue = null # Reset @@ -183,7 +264,12 @@ func test_sim_bridge_mock_dialogue_triggers_on_interact() -> void: var snap = SimBridge._test_snapshot() # tick 3 assert_that(snap.current_dialogue).is_not_null() assert_that(snap.current_dialogue.npc_name).is_equal("Kael") + assert_that(snap.current_dialogue.npc_entity_id).is_equal(2) assert_that(snap.current_dialogue.options.size()).is_equal(3) + assert_that(snap.current_dialogue.options[0].response_id).is_equal("kael_greet_01") + assert_that(snap.current_dialogue.options[0].confrontation).is_false() + assert_that(snap.current_dialogue.options[2].response_id).is_equal("kael_confront_01") + assert_that(snap.current_dialogue.options[2].confrontation).is_true() func test_sim_bridge_mock_dialogue_sustained() -> void: @@ -258,6 +344,14 @@ func test_full_v7_snapshot_decode() -> void: "visible_tiles": [], "nearby_interactions": [], "current_monologue": null, + "current_dialogue": { + "npc_name": "Sera", + "speech": "What brings you here?", + "options": [ + {"text": "Just looking around.", "response_id": "sera_01", "priority": 2}, + {"text": "I have questions.", "response_id": "sera_02", "priority": 1}, + ], + }, "pending_recognitions": [ {"entity_id": 50, "x": 14.0, "y": 11.0, "z": 0, "remaining_ticks": 3, "total_delay_ticks": 6}, {"entity_id": 51, "x": 8.0, "y": 13.0, "z": 0, "remaining_ticks": 0, "total_delay_ticks": 6}, @@ -272,6 +366,10 @@ func test_full_v7_snapshot_decode() -> void: 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) + assert_that(snapshot.current_dialogue).is_not_null() + assert_that(snapshot.current_dialogue.npc_name).is_equal("Sera") + assert_that(snapshot.current_dialogue.options.size()).is_equal(2) + assert_that(snapshot.current_dialogue.options[0].response_id).is_equal("sera_01") assert_that(snapshot.pending_recognitions.size()).is_equal(2) assert_that(snapshot.pending_recognitions[0].remaining_ticks).is_equal(3) assert_that(snapshot.pending_recognitions[1].remaining_ticks).is_equal(0)