test(client): add boundary, P0 regression, P1 fog/entity, protocol v7 tests

MessagePack boundary tests: 41 values (25 pos + 16 neg) with encode-
only verification, roundtrip, and Rust-style unsigned decode (#470).
P0 regressions: monologue carry-forward (Bug #5), camera stability
during pause (Bug #2) (#477). P1 tests: fog shader state (4), entity
lifecycle (2), pending recognition blob (1) using FogState named
constants (#478). Protocol v7: structured dialogue options with
response_id, priority, confrontation flag, malformed skip (#435).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 16:35:19 +01:00
co-authored by Claude Opus 4.6
parent be763908b2
commit afc433be34
4 changed files with 766 additions and 9 deletions
+206
View File
@@ -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()
+228
View File
@@ -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)
+225
View File
@@ -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)
+107 -9
View File
@@ -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)