Merge origin/client into main

This commit is contained in:
2026-02-11 20:26:17 +01:00
21 changed files with 867 additions and 80 deletions
+5
View File
@@ -14,6 +14,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- 8-directional movement — diagonal PlayerAction variants (MoveNortheast/Northwest/Southeast/Southwest) + TilePosition::all_neighbors()
- Entity-entity collision in validate_movement — spatial occupancy check prevents multiple entities on same tile
- 25 new tests (4 framing + 2 IPC integration + 17 movement unit + 2 movement integration), total 45
- MessagePack serialization for GDScript (ticket #77) — Protocol codec decoding ObserverSnapshot/PlayerInput from Rust wire format, encoding PlayerInput for server
- Godot4MessagePack library (pure GDScript) for MessagePack encode/decode
- Rust fixture generator (gen_fixtures.rs) producing canonical .msgpack test fixtures with rmp_serde
- 8 cross-language protocol tests verifying Rust↔GDScript MessagePack compatibility (D-030 Layer 1)
- SimBridge wired to Protocol codec with receive_bytes()/drain_outbound() for transport layer
- `db/connectors/ticket` CLI — ergonomic ticket management (list, show, create, assign, sprint, deps, search, epics, children, count) with JSON output
- Sprint 1 "Run" created with 8 stories targeting moving character on screen via IPC bridge
- Ticket skill rewritten to use ticket CLI instead of raw SQL wrapper scripts
+3
View File
@@ -73,6 +73,9 @@ test: test-server test-client
test-server:
cd server && cargo nextest run
fixtures:
cd server && cargo test --test gen_fixtures -- --ignored
test-client:
@test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; }
$(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Luis Chirlaque Hernández
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+368
View File
@@ -0,0 +1,368 @@
class_name Messagepack
## Messagepack implementation for Godot 4 in GDScript
##
## You can find the full spec at: https://github.com/msgpack/msgpack/blob/master/spec.md
const FIRST3 = 0xe0
const FIRST4 = 0xf0
const LAST4 = 0x0f
const LAST5 = 0x1f
const types = {
"nil": 0xc0,
"false": 0xc2,
"true": 0xc3,
"positive_fixint": [0x00, 0x7f],
"negative_fixint": [0xe0, 0xff],
"uint_8": 0xcc,
"uint_16": 0xcd,
"uint_32": 0xce,
"uint_64": 0xcf,
"int_8": 0xd0,
"int_16": 0xd1,
"int_32": 0xd2,
"int_64": 0xd3,
"float_32": 0xca,
"float_64": 0xcb,
"fixstr": [0xa0, 0xbf],
"str_8": 0xd9,
"str_16": 0xda,
"str_32": 0xdb,
"fixarray": [0x90, 0x9f],
"array_16": 0xdc,
"array_32": 0xdd,
"fixmap": [0x80, 0x8f],
"map_16": 0xde,
"map_32": 0xdf,
"bin_8": 0xc4,
"bin_16": 0xc5,
"bin_32": 0xc6
}
## This function takes a Variant and encodes it according to the Messagepack spec
##
## Parameters:
## - value: Variant to be encoded
##
## Returns:
## A dictionary containing the status of the encoding and the value as a PackedByteArray
static func encode(value) -> Dictionary:
var buffer = StreamPeerBuffer.new()
buffer.set_big_endian(true)
var err = _encode_message(buffer, value)
return {
value = buffer.data_array,
status = err
}
static func _encode_message(buffer: StreamPeerBuffer, value):
match typeof(value):
TYPE_NIL:
buffer.put_u8(types["nil"])
TYPE_BOOL:
if value == true:
buffer.put_u8(types["true"])
else:
buffer.put_u8(types["false"])
TYPE_INT:
if - (1 << 5) <= value and value <= (1 << 7) - 1:
buffer.put_8(value)
elif - (1 << 7) <= value and value <= (1 << 7):
buffer.put_u8(types["int_8"])
buffer.put_8(value)
elif 0 <= value and value <= (1 << 8) - 1:
buffer.put_u8(types["uint_8"])
buffer.put_u8(value)
elif - (1 << 15) <= value and value <= (1 << 15):
buffer.put_u8(types["int_16"])
buffer.put_16(value)
elif 0 <= value and value <= (1 << 16) - 1:
buffer.put_u8(types["uint_16"])
buffer.put_u16(value)
elif - (1 << 31) <= value and value <= (1 << 31):
buffer.put_u8(types["int_32"])
buffer.put_32(value)
elif 0 <= value and value <= (1 << 32) - 1:
buffer.put_u8(types["uint_32"])
buffer.put_u32(value)
elif - (1 << 63) <= value and value <= (1 << 63):
buffer.put_u8(types["int_64"])
buffer.put_64(value)
else:
buffer.put_u8(types["uint_64"])
buffer.put_u64(value)
TYPE_FLOAT:
buffer.put_u8(types["float_32"])
buffer.put_float(value)
TYPE_STRING:
var bytes = value.to_utf8_buffer()
var size = bytes.size()
if size <= (1 << 5) - 1:
buffer.put_u8(types["fixstr"][0]|size)
elif size <= (1 << 8) - 1:
buffer.put_u8(types["str_8"])
buffer.put_u8(size)
elif size <= (1 << 16) - 1:
buffer.put_u8(types["str_16"])
buffer.put_u16(size)
elif size <= (1 << 32) - 1:
buffer.put_u8(types["str_32"])
buffer.put_u32(size)
else:
printerr("Unsupported string: string is too big")
return ERR_INVALID_DATA
buffer.put_data(bytes)
TYPE_ARRAY:
var size = value.size()
if size <= 15:
buffer.put_u8(types["fixarray"][0]|size)
elif size <= (1 << 16) - 1:
buffer.put_u8(types["array_16"])
buffer.put_u16(size)
elif size <= (1 << 32) - 1:
buffer.put_u8(types["array_32"])
buffer.put_u32(size)
else:
printerr("Unsupported array: array is too long")
return ERR_INVALID_DATA
for obj in value:
_encode_message(buffer, obj)
TYPE_DICTIONARY:
var size = value.size()
if size <= 15:
buffer.put_u8(types["fixmap"][0]|size)
elif size <= (1 << 16) - 1:
buffer.put_u8(types["map_16"])
buffer.put_u16(size)
elif size <= (1 << 32) - 1:
buffer.put_u8(types["map_32"])
buffer.put_u32(size)
else:
printerr("Unsupported dictionary: dictionary is too big")
return ERR_INVALID_DATA
for key in value:
_encode_message(buffer, key)
_encode_message(buffer, value[key])
TYPE_PACKED_BYTE_ARRAY:
var size = value.size()
if size <= (1 << 8) - 1:
buffer.put_u8(types["bin_8"])
buffer.put_u8(size)
elif size <= (1 << 16) - 1:
buffer.put_u8(types["bin_16"])
buffer.put_u16(size)
elif size <= (1 << 32) - 1:
buffer.put_u8(types["bin_32"])
buffer.put_u32(size)
else:
printerr("Unsupported packed byte array: packed byte array is too big")
return ERR_INVALID_DATA
buffer.put_data(value)
_:
printerr("Unsupported data type: %s" % typeof(value))
return ERR_UNAVAILABLE
## This function takes a PackedByteArray and decodes it according to the Messagepack spec
##
## Parameters:
## - bytes: PackedByteArray to be decoded
##
## Returns:
## A dictionary containing the status of the decoding and the value as Godot Variants
static func decode(bytes: PackedByteArray):
var buffer = StreamPeerBuffer.new()
buffer.set_big_endian(true)
buffer.set_data_array(bytes)
var err = {
error = null
}
var message = _decode_message(buffer, err)
return {
value = message,
status = err.error
}
static func _decode_message(buffer: StreamPeerBuffer, err: Dictionary):
var buffer_size = buffer.get_size()
var first_byte = buffer.get_u8()
if first_byte & 0x80 == 0: # positive fixint
return first_byte
elif first_byte & FIRST4 == 0x80: # fixmap
var size = first_byte & 0x0f
var dict = {}
for _x in range(size):
var key = _decode_message(buffer, err)
var val = _decode_message(buffer, err)
dict[key] = val
return dict
elif first_byte & FIRST4 == 0x90: # fixarray
var size = first_byte & 0x0f
var array = []
for _x in range(size):
var val = _decode_message(buffer, err)
array.append(val)
return array
elif first_byte & FIRST3 == 0xa0: # fixstr
var size = first_byte & 0x1f
return buffer.get_utf8_string(size)
elif first_byte == types["nil"]: # nil
print("null size:%s"%buffer_size)
return null
elif first_byte == types["false"]: # false
print("false size:%s"%buffer_size)
return false
elif first_byte == types["true"]: # true
return true
elif first_byte == types["bin_8"]: # bin 8
var length = buffer.get_u8()
return buffer.get_partial_data(length)
elif first_byte == types["bin_16"]: # bin 16
var length = buffer.get_u16()
return buffer.get_partial_data(length)
elif first_byte == types["bin_32"]: # bin 32
var length = buffer.get_u32()
return buffer.get_partial_data(length)
elif first_byte == 0xc7: # ext 8
print("Ext 8 type not implemented")
return null
elif first_byte == 0xc8: # ext 16
print("Ext 16 type not implemented")
return null
elif first_byte == 0xc9: # ext 32
print("Ext 32 type not implemented")
return null
elif first_byte == types["float_32"]: # float 32
return buffer.get_float()
elif first_byte == types["float_64"]: # float 64
return buffer.get_double()
elif first_byte == types["uint_8"]: # uint 8
return buffer.get_u8()
elif first_byte == types["uint_16"]: # uint 16
return buffer.get_u16()
elif first_byte == types["uint_32"]: # uint 32
return buffer.get_u32()
elif first_byte == types["uint_64"]: # uint 64
return buffer.get_u64()
elif first_byte == types["int_8"]: # int 8
return buffer.get_8()
elif first_byte == types["int_16"]: # int 16
return buffer.get_16()
elif first_byte == types["int_32"]: # int 32
return buffer.get_32()
elif first_byte == types["int_64"]: # int 64
return buffer.get_64()
elif first_byte == 0xd4: # fixext 1
print("Fixext 1 type not implemented")
err.error = ERR_UNAVAILABLE
return null
elif first_byte == 0xd5: # fixext 2
print("Fixext 2 type not implemented")
err.error = ERR_UNAVAILABLE
return null
elif first_byte == 0xd6: # fixext 4
print("Fixext 4 type not implemented")
err.error = ERR_UNAVAILABLE
return null
elif first_byte == 0xd7: # fixext 8
print("Fixext 8 type not implemented")
err.error = ERR_UNAVAILABLE
return null
elif first_byte == 0xd8: # fixext 16
print("Fixext 16 type not implemented")
err.error = ERR_UNAVAILABLE
return null
elif first_byte == types["str_8"]: # str 8
var size = buffer.get_u8()
return buffer.get_utf8_string(size)
elif first_byte == types["str_16"]: # str 16
var size = buffer.get_u16()
return buffer.get_utf8_string(size)
elif first_byte == types["str_32"]: # str 32
var size = buffer.get_u32()
return buffer.get_utf8_string(size)
elif first_byte == types["array_16"]: # array 16
var length = buffer.get_u16()
var array = []
for _x in range(length):
var val = _decode_message(buffer, err)
array.append(val)
return array
elif first_byte == types["array_32"]: # array 32
var length = buffer.get_u32()
var array = []
for _x in range(length):
var val = _decode_message(buffer, err)
array.append(val)
return array
elif first_byte == types["map_16"]: # map 16
var length = buffer.get_u16()
var dict = {}
for _x in range(length):
var key = _decode_message(buffer, err)
var val = _decode_message(buffer, err)
dict[key] = val
return dict
elif first_byte == types["map_32"]: # map 32
var length = buffer.get_u32()
var dict = {}
for _x in range(length):
var key = _decode_message(buffer, err)
var val = _decode_message(buffer, err)
dict[key] = val
return dict
elif first_byte & FIRST3 == 0xe0: # negative fixint
return first_byte - 256
else:
printerr("Unknown header")
err.error = ERR_UNAVAILABLE
return null
@@ -0,0 +1 @@
uid://komeseatyar0
+14 -19
View File
@@ -1,31 +1,26 @@
extends Node
# Updated each frame from ObserverSnapshot data
# Updated each frame from ObserverSnapshot data (Protocol format: {tick, entities}).
# Entities use Protocol decoded format: {entity_id, x, y, z, kind: {variant, data}}.
var current_snapshot: Dictionary = {}
var current_tick: int = 0
var player_position: Vector2 = Vector2.ZERO
var visible_entities: Array = []
var fog_state: Dictionary = {}
var hud_data: Dictionary = {}
# Player entity ID — the first entity is assumed to be the player (will be
# refined when the server assigns explicit player entity IDs).
var player_entity_id: int = 1
func apply_snapshot(snapshot: Dictionary) -> void:
current_snapshot = snapshot
# Parse player data
if snapshot.has("player") and snapshot.player.has("position"):
var pos = snapshot.player.position
if pos is Array and pos.size() >= 2:
player_position = Vector2(pos[0], pos[1])
else:
push_warning("GameState: malformed player position in snapshot")
if snapshot.has("tick"):
current_tick = snapshot.tick
# Parse entities
if snapshot.has("entities"):
visible_entities = snapshot.entities
# Parse fog state
if snapshot.has("fog"):
fog_state = snapshot.fog
# Parse HUD data
if snapshot.has("hud"):
hud_data = snapshot.hud
# Derive player position from the player entity
for entity in visible_entities:
if entity.has("entity_id") and entity.entity_id == player_entity_id:
player_position = Vector2(entity.x, entity.y)
break
+66 -21
View File
@@ -6,6 +6,8 @@ enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR }
var state: ConnectionState = ConnectionState.DISCONNECTED
var test_mode: bool = true # Enable test mode for development without Rust server
var _test_tick: int = 0
var _last_snapshot: Variant = null # Most recent decoded snapshot (consumed by poll_snapshot)
var _outbound_buffer: Array[PackedByteArray] = [] # Encoded inputs awaiting transport
# Signals
signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState)
@@ -35,14 +37,26 @@ func connect_to_sim() -> void:
func disconnect_from_sim() -> void:
_set_state(ConnectionState.DISCONNECTED)
# Send input to simulation (real implementation comes later)
# Send input to simulation server.
# player_input: Dictionary with "action" (int from InputMapper.Action enum) and "timestamp_msec".
# In test mode, inputs are silently dropped. Transport (ticket #79) will send the bytes.
func send_input(player_input: Dictionary) -> void:
if state != ConnectionState.CONNECTED:
return
# TODO: Serialize and send via MessagePack when IPC is implemented
pass
if test_mode:
return
var action_name := _action_enum_to_wire(player_input.get("action", -1))
if action_name.is_empty():
return
var tick: int = player_input.get("timestamp_msec", 0)
var encoded := Protocol.encode_player_input(tick, action_name)
if encoded.size() == 0:
push_error("SimBridge: failed to encode player input (action=%s)" % action_name)
return
_outbound_buffer.append(encoded)
# Poll for snapshot from simulation
# Poll for snapshot from simulation.
# In test mode returns hardcoded data. In live mode, returns the last decoded snapshot (if any).
func poll_snapshot() -> Variant:
if state != ConnectionState.CONNECTED:
return null
@@ -52,31 +66,62 @@ func poll_snapshot() -> Variant:
snapshot_received.emit(snapshot)
return snapshot
# TODO: Actual polling logic when IPC/MessagePack is implemented
if _last_snapshot != null:
var snapshot = _last_snapshot
_last_snapshot = null
snapshot_received.emit(snapshot)
return snapshot
return null
# Hardcoded test snapshot for development (deterministic per D-010 principle 4)
# Called by transport layer (ticket #79) when raw bytes arrive from the server.
# Latest-wins semantics: newer snapshots replace unconsumed ones. This is correct
# for real-time rendering (stale frames are worthless). Upgrade to queue if needed.
func receive_bytes(bytes: PackedByteArray) -> void:
var snapshot = Protocol.decode_snapshot(bytes)
if snapshot != null:
if _last_snapshot != null:
push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick])
_last_snapshot = snapshot
# Drain the outbound buffer. Called by transport layer (ticket #79) to get encoded messages.
func drain_outbound() -> Array[PackedByteArray]:
var messages = _outbound_buffer.duplicate()
_outbound_buffer.clear()
return messages
# Map InputMapper.Action enum values to wire-format action names (matching Rust PlayerAction).
# OPEN_MENU is client-only — no Rust equivalent, not sent over the wire.
static func _action_enum_to_wire(action: int) -> String:
match action:
InputMapper.Action.MOVE_NORTH: return "MoveNorth"
InputMapper.Action.MOVE_SOUTH: return "MoveSouth"
InputMapper.Action.MOVE_EAST: return "MoveEast"
InputMapper.Action.MOVE_WEST: return "MoveWest"
InputMapper.Action.INTERACT: return "Interact"
InputMapper.Action.USE_PERCEPTION_MODE: return "UsePerceptionMode"
InputMapper.Action.PAUSE: return "Pause"
InputMapper.Action.OPEN_MENU:
# Client-only action, not part of wire protocol
push_warning("SimBridge: OPEN_MENU is client-only, not sent to server")
return ""
_:
push_warning("SimBridge: unknown action enum %s" % action)
return ""
# Hardcoded test snapshot matching Protocol format (deterministic per D-010 principle 4).
# Uses the same {tick, entities} schema as Protocol.decode_snapshot() returns.
func _test_snapshot() -> Dictionary:
_test_tick += 1
return {
"tick": _test_tick,
"player": {
"position": [10, 10],
"health": 100
},
"entities": [
{
"id": 1,
"type": "npc",
"position": [12, 8],
"name": "Test NPC"
"entity_id": 1,
"x": 10.0,
"y": 10.0,
"z": 0,
"kind": { "variant": "Npc", "data": null },
},
],
"fog": {
"radius": 8
},
"hud": {
"perception_mode": "baseline",
"time": "08:00"
}
}
-10
View File
@@ -24,16 +24,6 @@ func _process(_delta: float) -> void:
# Track camera to player position (D-015)
camera.position = GameState.player_position * 32 # tile-space to pixel-space
# Update HUD
if hud and hud.has_method("update_from_hud_data"):
hud.update_from_hud_data(GameState.hud_data)
if snapshot.has("player") and snapshot.player.has("health"):
hud.update_health(snapshot.player.health)
# Wire monologue display (D-016 perception data path)
if snapshot.has("monologue") and monologue_display:
monologue_display.show_monologue(snapshot.monologue)
# Send queued input to simulation
var inputs = InputMapper.flush_queue()
for input in inputs:
+127
View File
@@ -0,0 +1,127 @@
class_name Protocol
## MessagePack codec for the Rust↔Godot wire protocol (D-020).
##
## Encodes/decodes ObserverSnapshot and PlayerInput to match
## rmp_serde's named-field encoding of server/src/bridge/types.rs.
##
## Wire format notes (rmp_serde with to_vec_named):
## Structs → msgpack maps with string keys
## Unit enum variants (no data) → bare strings ("MoveNorth", "Npc")
## Data enum variants → single-element maps ({"UsePerceptionMode": "thermal"})
# -- Decode: bytes from server → GDScript types --------------------------------
## Decode an ObserverSnapshot from MessagePack bytes.
## Returns { "tick": int, "entities": Array[Dictionary] } or null on error.
static func decode_snapshot(bytes: PackedByteArray) -> Variant:
var result = Messagepack.decode(bytes)
if result.status != null:
push_error("Protocol: msgpack decode failed: %s" % result.status)
return null
var raw = result.value
if not raw is Dictionary or not raw.has("tick") or not raw.has("entities"):
push_error("Protocol: snapshot missing required fields")
return null
var entities: Array[Dictionary] = []
var raw_entities: Array = raw["entities"]
var dropped := 0
for raw_entity in raw_entities:
var entity = _decode_entity(raw_entity)
if entity != null:
entities.append(entity)
else:
dropped += 1
if dropped > 0:
push_error("Protocol: %d/%d entities failed to decode (D-010 information boundary violation)" % [dropped, raw_entities.size()])
# GDScript int is signed 64-bit. Rust tick is u64 but will not exceed 2^63
# in any realistic scenario (would require ~29 billion years at 10 ticks/game-minute per D-031).
var tick: int = raw["tick"]
return {
"tick": tick,
"entities": entities,
"decode_errors": dropped,
}
## Decode a single VisibleEntity from a raw msgpack map.
static func _decode_entity(raw: Dictionary) -> Variant:
if not raw.has("entity_id") or not raw.has("x") or not raw.has("y") \
or not raw.has("z") or not raw.has("kind"):
push_warning("Protocol: entity missing required fields: %s" % str(raw.keys()))
return null
var entity_id: int = raw["entity_id"]
return {
"entity_id": entity_id,
"x": float(raw["x"]),
"y": float(raw["y"]),
"z": int(raw["z"]),
"kind": _decode_enum_variant(raw["kind"]),
}
## Decode an enum variant from rmp_serde's encoding.
## Unit variants are bare strings, data variants are single-element maps.
## Returns { "variant": String, "data": Variant } in both cases.
static func _decode_enum_variant(raw) -> Dictionary:
if raw is String:
return { "variant": raw, "data": null }
elif raw is Dictionary and raw.size() == 1:
var variant_name: String = raw.keys()[0]
return { "variant": variant_name, "data": raw[variant_name] }
else:
push_warning("Protocol: unexpected enum encoding: %s" % str(raw))
return { "variant": "Unknown", "data": raw }
# -- Encode: GDScript types → bytes to server ----------------------------------
## Encode a PlayerInput to MessagePack bytes.
## action_name: one of "MoveNorth", "MoveSouth", "MoveEast", "MoveWest",
## "Interact", "UsePerceptionMode", "Pause", "Unpause"
## action_data: null for unit variants, String for UsePerceptionMode
static func encode_player_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray:
var action_value: Variant
if action_data != null:
# Data variant → single-element map
action_value = { action_name: action_data }
else:
# Unit variant → bare string
action_value = action_name
var input := {
"tick": tick,
"action": action_value,
}
var result = Messagepack.encode(input)
if result.status != null:
push_error("Protocol: msgpack encode failed: %s" % result.status)
return PackedByteArray()
return result.value
## Decode a PlayerInput from MessagePack bytes (used in tests / echo scenarios).
## Returns { "tick": int, "action": { "variant": String, "data": Variant } } or null.
static func decode_player_input(bytes: PackedByteArray) -> Variant:
var result = Messagepack.decode(bytes)
if result.status != null:
push_error("Protocol: msgpack decode failed: %s" % result.status)
return null
var raw = result.value
if not raw is Dictionary or not raw.has("tick") or not raw.has("action"):
push_error("Protocol: player_input missing required fields")
return null
var tick: int = raw["tick"]
return {
"tick": tick,
"action": _decode_enum_variant(raw["action"]),
}
+1
View File
@@ -0,0 +1 @@
uid://fkmd537xvwxe
+2 -2
View File
@@ -15,6 +15,6 @@ func update_from_state() -> void:
if entity_renderer and entity_renderer.has_method("update_entities"):
entity_renderer.update_entities(GameState.visible_entities)
# Update fog overlay
# Update fog overlay (fog data will come in D-020 expansion)
if fog_renderer and fog_renderer.has_method("update_fog"):
fog_renderer.update_fog(GameState.fog_state, GameState.player_position)
fog_renderer.update_fog({}, GameState.player_position)
@@ -0,0 +1 @@
うtickdヲactionゥMoveNorth
@@ -0,0 +1 @@
うtickフネヲactionUsePerceptionModeァthermal
Binary file not shown.
Binary file not shown.
Binary file not shown.
+169
View File
@@ -0,0 +1,169 @@
## D-030 Layer 1: Cross-language MessagePack tests
## Validates that Protocol.gd correctly decodes fixtures generated by Rust (rmp_serde).
## Fixtures generated by: cargo test --test gen_fixtures -- --ignored
class_name TestProtocol
extends GdUnitTestSuite
const FIXTURE_DIR = "res://tests/fixtures/msgpack/"
func _load_fixture(name: String) -> PackedByteArray:
var path = FIXTURE_DIR + name + ".msgpack"
var file = FileAccess.open(path, FileAccess.READ)
assert_that(file).is_not_null()
return file.get_buffer(file.get_length())
# -- Snapshot decoding ----------------------------------------------------------
func test_decode_snapshot_one_npc() -> void:
var bytes = _load_fixture("snapshot_one_npc")
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(42)
assert_that(snapshot.entities.size()).is_equal(1)
var entity = snapshot.entities[0]
assert_that(entity.entity_id).is_equal(1)
assert_float(entity.x).is_equal_approx(10.0, 0.001)
assert_float(entity.y).is_equal_approx(20.0, 0.001)
assert_that(entity.z).is_equal(0)
assert_that(entity.kind.variant).is_equal("Npc")
assert_that(entity.kind.data).is_null()
func test_decode_snapshot_empty() -> void:
var bytes = _load_fixture("snapshot_empty")
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(0)
assert_that(snapshot.entities.size()).is_equal(0)
func test_decode_snapshot_multi_entity() -> void:
var bytes = _load_fixture("snapshot_multi_entity")
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(999)
assert_that(snapshot.entities.size()).is_equal(3)
# NPC at (5, 10, 0)
var npc = snapshot.entities[0]
assert_that(npc.entity_id).is_equal(1)
assert_float(npc.x).is_equal_approx(5.0, 0.001)
assert_float(npc.y).is_equal_approx(10.0, 0.001)
assert_that(npc.z).is_equal(0)
assert_that(npc.kind.variant).is_equal("Npc")
# Object at (15.5, 3, 1)
var obj = snapshot.entities[1]
assert_that(obj.entity_id).is_equal(2)
assert_float(obj.x).is_equal_approx(15.5, 0.001)
assert_float(obj.y).is_equal_approx(3.0, 0.001)
assert_that(obj.z).is_equal(1)
assert_that(obj.kind.variant).is_equal("Object")
# Terrain at (0, 0, -1)
var terrain = snapshot.entities[2]
assert_that(terrain.entity_id).is_equal(3)
assert_float(terrain.x).is_equal_approx(0.0, 0.001)
assert_float(terrain.y).is_equal_approx(0.0, 0.001)
assert_that(terrain.z).is_equal(-1)
assert_that(terrain.kind.variant).is_equal("Terrain")
# -- PlayerInput decoding -------------------------------------------------------
func test_decode_input_move_north() -> void:
var bytes = _load_fixture("input_move_north")
var input = Protocol.decode_player_input(bytes)
assert_that(input).is_not_null()
assert_that(input.tick).is_equal(100)
assert_that(input.action.variant).is_equal("MoveNorth")
assert_that(input.action.data).is_null()
func test_decode_input_perception_mode() -> void:
var bytes = _load_fixture("input_perception_mode")
var input = Protocol.decode_player_input(bytes)
assert_that(input).is_not_null()
assert_that(input.tick).is_equal(200)
assert_that(input.action.variant).is_equal("UsePerceptionMode")
assert_that(input.action.data).is_equal("thermal")
# -- PlayerInput encoding -------------------------------------------------------
func test_encode_decode_roundtrip_unit_variant() -> void:
var bytes = Protocol.encode_player_input(50, "MoveEast")
assert_that(bytes.size()).is_greater(0)
var decoded = Protocol.decode_player_input(bytes)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(50)
assert_that(decoded.action.variant).is_equal("MoveEast")
assert_that(decoded.action.data).is_null()
func test_encode_decode_roundtrip_data_variant() -> void:
var bytes = Protocol.encode_player_input(75, "UsePerceptionMode", "infrared")
assert_that(bytes.size()).is_greater(0)
var decoded = Protocol.decode_player_input(bytes)
assert_that(decoded).is_not_null()
assert_that(decoded.tick).is_equal(75)
assert_that(decoded.action.variant).is_equal("UsePerceptionMode")
assert_that(decoded.action.data).is_equal("infrared")
# -- Cross-language roundtrip: GDScript encode matches Rust decode ---------------
func test_gdscript_encode_matches_rust_fixture() -> void:
# Encode the same MoveNorth input as the Rust fixture
var bytes = Protocol.encode_player_input(100, "MoveNorth")
# Decode and verify the content matches the Rust fixture
var rust_bytes = _load_fixture("input_move_north")
var from_gd = Protocol.decode_player_input(bytes)
var from_rust = Protocol.decode_player_input(rust_bytes)
assert_that(from_gd.tick).is_equal(from_rust.tick)
assert_that(from_gd.action.variant).is_equal(from_rust.action.variant)
assert_that(from_gd.action.data).is_equal(from_rust.action.data)
# -- Negative tests: malformed/truncated input -----------------------------------
func test_decode_snapshot_truncated_bytes() -> void:
var truncated := PackedByteArray([0x82, 0xa4]) # Incomplete msgpack map
var result = Protocol.decode_snapshot(truncated)
assert_that(result).is_null()
func test_decode_snapshot_wrong_type() -> void:
# Encode an array instead of a map — should fail validation
var encoded = Messagepack.encode([1, 2, 3])
var result = Protocol.decode_snapshot(encoded.value)
assert_that(result).is_null()
func test_decode_snapshot_missing_fields() -> void:
# Map with wrong keys
var encoded = Messagepack.encode({"foo": "bar"})
var result = Protocol.decode_snapshot(encoded.value)
assert_that(result).is_null()
func test_decode_player_input_empty_bytes() -> void:
var result = Protocol.decode_player_input(PackedByteArray())
assert_that(result).is_null()
func test_encode_produces_nonempty_bytes() -> void:
var bytes = Protocol.encode_player_input(1, "MoveNorth")
assert_that(bytes.size()).is_greater(0)
+1
View File
@@ -0,0 +1 @@
uid://cj0et712osytn
+22 -28
View File
@@ -1,30 +1,24 @@
## D-030 Layer 1: Fixture-based tests for snapshot parsing
## Validates that GameState correctly parses ObserverSnapshot data
## Validates that GameState correctly parses Protocol-format ObserverSnapshot data
class_name TestSnapshotParsing
extends GdUnitTestSuite
# Valid snapshot fixture
# Valid snapshot in Protocol format (matches Protocol.decode_snapshot output)
var _valid_snapshot: Dictionary = {
"tick": 1,
"player": {
"position": [10, 15],
"health": 85
},
"entities": [
{"id": 1, "type": "npc", "position": [12, 8], "name": "Test NPC"},
{"id": 2, "type": "npc", "position": [5, 20], "name": "Second NPC"},
{"entity_id": 1, "x": 10.0, "y": 15.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
{"entity_id": 2, "x": 5.0, "y": 20.0, "z": 0, "kind": {"variant": "Npc", "data": null}},
],
"fog": {"radius": 8},
"hud": {"perception_mode": "baseline", "time": "14:30"}
}
func test_apply_valid_snapshot() -> void:
GameState.player_entity_id = 1
GameState.apply_snapshot(_valid_snapshot)
assert_that(GameState.current_tick).is_equal(1)
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
assert_that(GameState.visible_entities.size()).is_equal(2)
assert_that(GameState.fog_state).is_equal({"radius": 8})
assert_that(GameState.hud_data).is_equal({"perception_mode": "baseline", "time": "14:30"})
func test_empty_snapshot_no_crash() -> void:
# Reset state
@@ -37,26 +31,25 @@ func test_empty_snapshot_no_crash() -> void:
assert_that(GameState.player_position).is_equal(Vector2.ZERO)
assert_that(GameState.visible_entities.size()).is_equal(0)
func test_malformed_position_no_crash() -> void:
var bad_snapshot: Dictionary = {
"player": {"position": "not_an_array"},
}
# Reset
GameState.player_position = Vector2.ZERO
func test_no_player_entity_position_unchanged() -> void:
GameState.player_entity_id = 999 # No entity with this ID
GameState.player_position = Vector2(5, 5)
# Should not crash — logs warning instead
GameState.apply_snapshot(bad_snapshot)
assert_that(GameState.player_position).is_equal(Vector2.ZERO)
GameState.apply_snapshot(_valid_snapshot)
# Position stays at previous value since no matching entity
assert_that(GameState.player_position).is_equal(Vector2(5, 5))
func test_missing_fields_partial_update() -> void:
# First apply valid snapshot
GameState.player_entity_id = 1
GameState.apply_snapshot(_valid_snapshot)
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
# Apply snapshot with only HUD data — player position unchanged
GameState.apply_snapshot({"hud": {"perception_mode": "thermal", "time": "22:00"}})
# Apply snapshot with no entities — player position unchanged (no matching entity)
GameState.apply_snapshot({"tick": 2})
assert_that(GameState.player_position).is_equal(Vector2(10, 15))
assert_that(GameState.hud_data.perception_mode).is_equal("thermal")
assert_that(GameState.current_tick).is_equal(2)
func test_sim_bridge_test_snapshot_deterministic() -> void:
SimBridge._test_tick = 0
@@ -65,8 +58,9 @@ func test_sim_bridge_test_snapshot_deterministic() -> void:
assert_that(snap1.tick).is_equal(1)
assert_that(snap2.tick).is_equal(2)
# Snapshot structure is stable
assert_that(snap1.has("player")).is_true()
# Snapshot matches Protocol format
assert_that(snap1.has("tick")).is_true()
assert_that(snap1.has("entities")).is_true()
assert_that(snap1.has("fog")).is_true()
assert_that(snap1.has("hud")).is_true()
assert_that(snap1.entities.size()).is_greater(0)
assert_that(snap1.entities[0].has("entity_id")).is_true()
assert_that(snap1.entities[0].has("kind")).is_true()
@@ -0,0 +1 @@
uid://ct5bcdvoyo65p
+64
View File
@@ -0,0 +1,64 @@
//! Generate MessagePack fixture files for cross-language testing (D-030 Layer 1).
//! Run with: cargo test --test gen_fixtures -- --ignored
use settled_reach_server::bridge::types::*;
use std::fs;
use std::path::Path;
fn write_fixture(name: &str, bytes: &[u8]) {
// Write directly into the Godot project's test fixtures (single source of truth)
let dir = Path::new("../client/tests/fixtures/msgpack");
fs::create_dir_all(dir).expect("create fixture dir");
let path = dir.join(format!("{}.msgpack", name));
fs::write(&path, bytes).expect("write fixture");
eprintln!("Wrote {} ({} bytes)", path.display(), bytes.len());
}
#[test]
#[ignore] // Run manually: cargo test --test gen_fixtures -- --ignored
fn generate_msgpack_fixtures() {
// Snapshot with one NPC entity
let snapshot = ObserverSnapshot {
tick: 42,
entities: vec![VisibleEntity {
entity_id: 1,
x: 10.0,
y: 20.0,
z: 0,
kind: EntityKind::Npc,
}],
};
write_fixture("snapshot_one_npc", &rmp_serde::to_vec_named(&snapshot).unwrap());
// Empty snapshot
let empty = ObserverSnapshot {
tick: 0,
entities: vec![],
};
write_fixture("snapshot_empty", &rmp_serde::to_vec_named(&empty).unwrap());
// PlayerInput: MoveNorth
let input_north = PlayerInput {
tick: 100,
action: PlayerAction::MoveNorth,
};
write_fixture("input_move_north", &rmp_serde::to_vec_named(&input_north).unwrap());
// PlayerInput: UsePerceptionMode
let input_perception = PlayerInput {
tick: 200,
action: PlayerAction::UsePerceptionMode("thermal".to_string()),
};
write_fixture("input_perception_mode", &rmp_serde::to_vec_named(&input_perception).unwrap());
// Snapshot with multiple entities and all EntityKind variants
let snapshot_multi = ObserverSnapshot {
tick: 999,
entities: vec![
VisibleEntity { entity_id: 1, x: 5.0, y: 10.0, z: 0, kind: EntityKind::Npc },
VisibleEntity { entity_id: 2, x: 15.5, y: 3.0, z: 1, kind: EntityKind::Object },
VisibleEntity { entity_id: 3, x: 0.0, y: 0.0, z: -1, kind: EntityKind::Terrain },
],
};
write_fixture("snapshot_multi_entity", &rmp_serde::to_vec_named(&snapshot_multi).unwrap());
}