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:
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user