Merge remote-tracking branch 'origin/client'

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
2026-02-11 22:04:04 +01:00
11 changed files with 409 additions and 26 deletions
+9
View File
@@ -13,6 +13,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Q-018 through Q-023 — 6 open questions from architecture audit (shadowcasting, entity ID stability, collision resolution, tick overflow, pathfinding cache, debug visualization)
- 5 architecture spike workshop briefs (knowledge graph, observer pipeline, NPC AI state machines, save/load, map authoring)
- 17 tickets from architecture audit (#339-#355) — 7 Sprint 1 tasks, 5 Sprint 2+ tasks, 5 workshop epics
- End-to-end connection test (#81) — GDScript test spawning Rust server, connecting via LocalBridge, sending MoveNorth input, verifying player movement in snapshot response (D-030 Layer 3)
- Batch input encoding (Vec\<PlayerInput\> wire format) — Protocol.encode_player_inputs() batches all inputs per tick into one framed message matching server expectations
- EntityKind::Player fixture — snapshot_player.msgpack for cross-language testing, multi-entity fixture updated to include all 4 entity kinds
- 7 new tests (4 batch encoding, 1 framed batch roundtrip, 1 Player fixture decode, 1 E2E connection), 43 total client tests passing
- LocalBridge GDScript TCP transport (#79) — 4-byte big-endian length-prefix framing matching Rust server, StreamPeerTCP wrapper with partial read handling
- ServerProcess subprocess manager — spawns/stops Rust server via OS.create_process(), auto-cleanup on destruction
- SimBridge live transport integration — _process() polling loop for TCP receive/send, connection state machine (DISCONNECTED → CONNECTING → CONNECTED → ERROR)
@@ -119,7 +123,12 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
- Rejected alternatives R-004 through R-010 documented (pure Bevy, pure Godot, GDExtension, C++ GDExtension, Fyrox, custom framework, protobuf)
### Fixed
<<<<<<< HEAD
- create-skill references to nonexistent init_skill.py and package_skill.py scripts
=======
- SimBridge wire format: inputs now batch-encoded as Vec\<PlayerInput\> array per server protocol (was sending individual inputs per frame)
- SimBridge server args: positional address format ("127.0.0.1:9876") matching server CLI, port default corrected to 9876
>>>>>>> origin/client
- Wire format mismatch: LocalBridge now uses rmp_serde::to_vec_named() (named maps) matching client Protocol.gd expectations
- EOF on bridge read now returns BridgeError::Transport for disconnect detection instead of empty Vec
- WalkabilityMap rewritten from flat Vec to chunk-based HashMap per D-012 architecture (Tyre review)
+26 -17
View File
@@ -7,12 +7,12 @@ 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
var _outbound_buffer: Array[Dictionary] = [] # Raw inputs awaiting batch encode + transport
# Transport layer (non-test mode)
var _bridge: LocalBridge = null
var _server: ServerProcess = null
var server_port: int = 9800
var server_port: int = 9876 # Default matches server's default bind address
var server_path: String = "" # Path to server binary — set before connect_to_sim()
# Connection retry state — handles server startup delay (Critical fix #1)
@@ -50,7 +50,8 @@ func connect_to_sim() -> void:
# Spawn server subprocess
if not server_path.is_empty():
_server = ServerProcess.new()
var pid := _server.start(server_path, ["--port", str(server_port)])
# Server reads first positional arg as bind address (e.g. "127.0.0.1:9876")
var pid := _server.start(server_path, ["127.0.0.1:" + str(server_port)])
if pid <= 0:
push_error("SimBridge: failed to start server")
_set_state(ConnectionState.ERROR)
@@ -129,12 +130,19 @@ func _process(delta: float) -> void:
while msg.size() > 0:
receive_bytes(msg)
msg = _bridge.poll_message()
# Send: flush outbound buffer through the bridge
# Send: batch-encode and flush outbound buffer as one frame (Vec<PlayerInput>).
# Inputs are drained before encoding. On encode failure the inputs are
# intentionally dropped — re-queuing would retry the same bad data and
# the server tick has already advanced, making stale inputs invalid.
var outbound := drain_outbound()
for payload in outbound:
var err := _bridge.send_message(payload)
if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err))
if outbound.size() > 0:
var encoded := Protocol.encode_player_inputs(outbound)
if encoded.size() > 0:
var err := _bridge.send_message(encoded)
if err != OK:
push_error("SimBridge: failed to send message: %s" % error_string(err))
else:
push_error("SimBridge: failed to batch-encode %d inputs (dropped)" % outbound.size())
StreamPeerTCP.STATUS_CONNECTING:
pass # Should not happen in CONNECTED state
StreamPeerTCP.STATUS_ERROR:
@@ -160,11 +168,12 @@ func send_input(player_input: Dictionary) -> Error:
# _action_enum_to_wire already emits push_warning for invalid actions
return ERR_INVALID_PARAMETER
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 ERR_CANT_CREATE
_outbound_buffer.append(encoded)
var entry: Dictionary = { "tick": tick, "action_name": action_name }
# Data variants (e.g. UsePerceptionMode) carry payload
var action_data: Variant = player_input.get("action_data")
if action_data != null:
entry["action_data"] = action_data
_outbound_buffer.append(entry)
return OK
# Poll for snapshot from simulation.
@@ -196,11 +205,11 @@ func receive_bytes(bytes: PackedByteArray) -> void:
push_warning("SimBridge: overwriting unconsumed snapshot (tick %s replaced by %s)" % [_last_snapshot.tick, snapshot.tick])
_last_snapshot = snapshot
# Drain the outbound buffer. Returns encoded messages for transport.
func drain_outbound() -> Array[PackedByteArray]:
var messages = _outbound_buffer.duplicate()
# Drain the outbound buffer. Returns raw input entries for batch encoding.
func drain_outbound() -> Array[Dictionary]:
var inputs = _outbound_buffer.duplicate()
_outbound_buffer.clear()
return messages
return inputs
# 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.
+26
View File
@@ -107,6 +107,32 @@ static func encode_player_input(tick: int, action_name: String, action_data: Var
return result.value
## Encode an array of PlayerInputs to MessagePack bytes (Vec<PlayerInput> wire format).
## Server expects one framed message per tick containing all inputs as a msgpack array.
## Each entry: { "tick": int, "action_name": String, "action_data": Variant (optional) }
static func encode_player_inputs(inputs: Array) -> PackedByteArray:
var wire_inputs: Array = []
for input in inputs:
var action_name: String = input["action_name"]
var action_data: Variant = input.get("action_data")
var action_value: Variant
if action_data != null:
action_value = { action_name: action_data }
else:
action_value = action_name
wire_inputs.append({
"tick": input["tick"],
"action": action_value,
})
var result = Messagepack.encode(wire_inputs)
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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
+120
View File
@@ -0,0 +1,120 @@
## D-030 Layer 3: End-to-end connection test
## Spawns the Rust simulation server, connects via LocalBridge,
## sends a MoveNorth input, and verifies the snapshot response.
## Requires: server binary built (cargo build in server/)
class_name TestE2EConnection
extends GdUnitTestSuite
const CONNECT_TIMEOUT: float = 3.0
const RESPONSE_TIMEOUT: float = 5.0
const MAX_PORT_ATTEMPTS: int = 5
var _server_pid: int = -1
var _bridge: LocalBridge = null
var _test_port: int = 0
func _server_binary_path() -> String:
var project_dir := ProjectSettings.globalize_path("res://")
return project_dir.path_join("../server/target/debug/settled-reach-server")
## Pick a random high port to avoid conflicts in parallel CI runs.
## Range 49152-65535 is the dynamic/ephemeral port range (IANA).
static func _random_test_port() -> int:
return 49152 + (randi() % (65535 - 49152 + 1))
## Spawn server with port rotation — if the port is in use, the server exits
## immediately (bind failure). Detect this and retry with a new random port.
func _spawn_server(server_path: String) -> bool:
for attempt in range(MAX_PORT_ATTEMPTS):
_test_port = _random_test_port()
var addr := "127.0.0.1:%d" % _test_port
_server_pid = OS.create_process(server_path, [addr])
if _server_pid <= 0:
continue
# Give server time to bind or fail
await get_tree().create_timer(0.15).timeout
if OS.is_process_running(_server_pid):
return true
# Server exited — port likely in use, try another
_server_pid = -1
return false
func after_test() -> void:
if _bridge != null:
_bridge.disconnect_from_server()
_bridge = null
if _server_pid > 0 and OS.is_process_running(_server_pid):
OS.kill(_server_pid)
_server_pid = -1
# -- E2E: full round-trip through server binary --------------------------------
func test_send_input_receive_snapshot() -> void:
var server_path := _server_binary_path()
if not FileAccess.file_exists(server_path):
push_warning("E2E test skipped: server binary not found at %s" % server_path)
return
# Spawn server with port rotation (retries if port is in use)
var spawned := await _spawn_server(server_path)
assert_bool(spawned).is_true()
# Connect with retries (server needs time to accept)
_bridge = LocalBridge.new()
var connected := false
var elapsed := 0.0
while elapsed < CONNECT_TIMEOUT:
if _bridge.get_status() == StreamPeerTCP.STATUS_NONE:
_bridge.connect_to_server("127.0.0.1", _test_port)
_bridge.poll()
if _bridge.get_status() == StreamPeerTCP.STATUS_CONNECTED:
connected = true
break
if _bridge.get_status() == StreamPeerTCP.STATUS_ERROR:
_bridge.disconnect_from_server()
_bridge.reset()
await get_tree().create_timer(0.1).timeout
elapsed += 0.1
assert_bool(connected).is_true()
# Send batch input: MoveNorth at tick 0 (matching game_loop.rs test)
var inputs: Array = [{"tick": 0, "action_name": "MoveNorth"}]
var encoded := Protocol.encode_player_inputs(inputs)
assert_that(encoded.size()).is_greater(0)
var send_err := _bridge.send_message(encoded)
assert_that(send_err).is_equal(OK)
# Poll for snapshot response
var snapshot_bytes := PackedByteArray()
elapsed = 0.0
while elapsed < RESPONSE_TIMEOUT:
_bridge.poll()
snapshot_bytes = _bridge.poll_message()
if snapshot_bytes.size() > 0:
break
await get_tree().create_timer(0.05).timeout
elapsed += 0.05
assert_that(snapshot_bytes.size()).is_greater(0)
# Decode snapshot
var snapshot: Variant = Protocol.decode_snapshot(snapshot_bytes)
assert_that(snapshot).is_not_null()
# Server starts at tick 0, snapshot reflects state after processing
assert_that(snapshot.tick).is_equal(0)
assert_that(snapshot.entities.size()).is_equal(1)
# Player started at (16, 16, 0), moved north (y-1) to (16, 15, 0)
# Render coords: tile center offset -> (16.5, 15.5, 0)
var player: Dictionary = snapshot.entities[0]
assert_float(player.x).is_equal_approx(16.5, 0.001)
assert_float(player.y).is_equal_approx(15.5, 0.001)
assert_that(player.z).is_equal(0)
assert_that(player.kind.variant).is_equal("Player")
+22
View File
@@ -123,6 +123,28 @@ func test_framed_protocol_input_roundtrip() -> void:
assert_that(input.action.variant).is_equal("MoveNorth")
func test_framed_protocol_batch_input_roundtrip() -> void:
# Encode a batch of inputs (Vec<PlayerInput>), frame it, decode frame, verify wire format
var inputs: Array = [
{"tick": 0, "action_name": "MoveNorth"},
{"tick": 0, "action_name": "Interact"},
]
var encoded := Protocol.encode_player_inputs(inputs)
assert_that(encoded.size()).is_greater(0)
var framed := LocalBridge.frame_encode(encoded)
var decoded_frame: Variant = LocalBridge.frame_decode(framed)
assert_that(decoded_frame).is_not_null()
# Verify the payload is a valid msgpack array matching server expectations
var raw: Variant = Messagepack.decode(decoded_frame.payload)
assert_that(raw.status).is_null()
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(2)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["action"]).is_equal("Interact")
# -- Diagonal movement wire mapping --------------------------------------------
func test_action_enum_to_wire_all_directions_clockwise() -> void:
+124 -7
View File
@@ -42,33 +42,58 @@ func test_decode_snapshot_empty() -> void:
assert_that(snapshot.entities.size()).is_equal(0)
func test_decode_snapshot_player() -> void:
var bytes = _load_fixture("snapshot_player")
var snapshot = Protocol.decode_snapshot(bytes)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(1)
assert_that(snapshot.entities.size()).is_equal(1)
var player = snapshot.entities[0]
assert_that(player.entity_id).is_equal(100)
assert_float(player.x).is_equal_approx(16.5, 0.001)
assert_float(player.y).is_equal_approx(16.5, 0.001)
assert_that(player.z).is_equal(0)
assert_that(player.kind.variant).is_equal("Player")
assert_that(player.kind.data).is_null()
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)
assert_that(snapshot.entities.size()).is_equal(4)
# Player at (16.5, 16.5, 0)
var player = snapshot.entities[0]
assert_that(player.entity_id).is_equal(1)
assert_float(player.x).is_equal_approx(16.5, 0.001)
assert_float(player.y).is_equal_approx(16.5, 0.001)
assert_that(player.z).is_equal(0)
assert_that(player.kind.variant).is_equal("Player")
# NPC at (5, 10, 0)
var npc = snapshot.entities[0]
assert_that(npc.entity_id).is_equal(1)
var npc = snapshot.entities[1]
assert_that(npc.entity_id).is_equal(2)
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)
var obj = snapshot.entities[2]
assert_that(obj.entity_id).is_equal(3)
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)
var terrain = snapshot.entities[3]
assert_that(terrain.entity_id).is_equal(4)
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)
@@ -159,6 +184,26 @@ func test_decode_snapshot_missing_fields() -> void:
assert_that(result).is_null()
func test_decode_snapshot_malformed_entities_counted() -> void:
# Snapshot with one valid and one malformed entity — decode_errors should count the bad one
var raw := {
"tick": 7,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 10.0, "z": 0, "kind": "Npc"},
{"entity_id": 2, "broken": true}, # Missing required fields
{"x": 1.0}, # Missing entity_id, y, z, kind
],
}
var encoded: Variant = Messagepack.encode(raw)
assert_that(encoded.status).is_null()
var snapshot: Variant = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.tick).is_equal(7)
assert_that(snapshot.entities.size()).is_equal(1) # Only the valid entity
assert_that(snapshot.decode_errors).is_equal(2) # Two malformed entities
func test_decode_player_input_empty_bytes() -> void:
var result = Protocol.decode_player_input(PackedByteArray())
assert_that(result).is_null()
@@ -169,6 +214,78 @@ func test_encode_produces_nonempty_bytes() -> void:
assert_that(bytes.size()).is_greater(0)
# -- Batch input encoding (Vec<PlayerInput> wire format) -----------------------
func test_encode_player_inputs_single() -> void:
var inputs: Array = [{"tick": 10, "action_name": "MoveNorth"}]
var bytes := Protocol.encode_player_inputs(inputs)
assert_that(bytes.size()).is_greater(0)
# Decode as raw msgpack — should be an array with one element
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(1)
assert_that(raw.value[0]["tick"]).is_equal(10)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
func test_encode_player_inputs_multiple() -> void:
var inputs: Array = [
{"tick": 1, "action_name": "MoveNorth"},
{"tick": 1, "action_name": "Interact"},
{"tick": 2, "action_name": "MoveSouthwest"},
]
var bytes := Protocol.encode_player_inputs(inputs)
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value.size()).is_equal(3)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["action"]).is_equal("Interact")
assert_that(raw.value[2]["action"]).is_equal("MoveSouthwest")
func test_encode_player_inputs_with_data_variant() -> void:
var inputs: Array = [
{"tick": 5, "action_name": "UsePerceptionMode", "action_data": "thermal"},
]
var bytes := Protocol.encode_player_inputs(inputs)
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value[0]["action"] is Dictionary).is_true()
assert_that(raw.value[0]["action"]["UsePerceptionMode"]).is_equal("thermal")
func test_encode_player_inputs_empty() -> void:
var inputs: Array = []
var bytes := Protocol.encode_player_inputs(inputs)
assert_that(bytes.size()).is_greater(0)
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(0)
# -- Batch input fixture (D-030 Layer 1 bidirectional symmetry) ----------------
func test_decode_batch_input_fixture() -> void:
# Rust-generated Vec<PlayerInput> fixture — verifies bidirectional Layer 1 compatibility
var bytes = _load_fixture("input_batch_two")
var raw: Variant = Messagepack.decode(bytes)
assert_that(raw.status).is_null()
assert_that(raw.value is Array).is_true()
assert_that(raw.value.size()).is_equal(2)
assert_that(raw.value[0]["tick"]).is_equal(0)
assert_that(raw.value[0]["action"]).is_equal("MoveNorth")
assert_that(raw.value[1]["tick"]).is_equal(0)
assert_that(raw.value[1]["action"]).is_equal("Interact")
# -- Diagonal movement fixtures (D-030 Layer 1 cross-language) -----------------
func test_decode_diagonal_fixtures() -> void:
+41 -2
View File
@@ -60,26 +60,49 @@ fn generate_msgpack_fixtures() {
&rmp_serde::to_vec_named(&input_perception).unwrap(),
);
// Snapshot with Player entity (EntityKind::Player added by server team)
let snapshot_player = ObserverSnapshot {
tick: 1,
entities: vec![VisibleEntity {
entity_id: 100,
x: 16.5,
y: 16.5,
z: 0,
kind: EntityKind::Player,
}],
};
write_fixture(
"snapshot_player",
&rmp_serde::to_vec_named(&snapshot_player).unwrap(),
);
// Snapshot with multiple entities and all EntityKind variants
let snapshot_multi = ObserverSnapshot {
tick: 999,
entities: vec![
VisibleEntity {
entity_id: 1,
x: 16.5,
y: 16.5,
z: 0,
kind: EntityKind::Player,
},
VisibleEntity {
entity_id: 2,
x: 5.0,
y: 10.0,
z: 0,
kind: EntityKind::Npc,
},
VisibleEntity {
entity_id: 2,
entity_id: 3,
x: 15.5,
y: 3.0,
z: 1,
kind: EntityKind::Object,
},
VisibleEntity {
entity_id: 3,
entity_id: 4,
x: 0.0,
y: 0.0,
z: -1,
@@ -92,6 +115,22 @@ fn generate_msgpack_fixtures() {
&rmp_serde::to_vec_named(&snapshot_multi).unwrap(),
);
// Batch input: Vec<PlayerInput> with two actions (D-030 Layer 1 bidirectional symmetry)
let input_batch = vec![
PlayerInput {
tick: 0,
action: PlayerAction::MoveNorth,
},
PlayerInput {
tick: 0,
action: PlayerAction::Interact,
},
];
write_fixture(
"input_batch_two",
&rmp_serde::to_vec_named(&input_batch).unwrap(),
);
// Diagonal movement fixtures (clockwise: NE, SE, SW, NW)
for (name, action) in [
("input_move_northeast", PlayerAction::MoveNortheast),
+41
View File
@@ -1,6 +1,7 @@
//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
use settled_reach_server::bridge::types::*;
use std::fs;
#[test]
fn observer_snapshot_roundtrip() {
@@ -82,6 +83,46 @@ fn all_player_action_variants_roundtrip() {
}
}
/// All .msgpack fixtures must deserialize without error (guards against corruption in git).
/// Snapshot fixtures deserialize as ObserverSnapshot, input_* as PlayerInput,
/// input_batch_* as Vec<PlayerInput>.
#[test]
fn all_fixtures_deserialize() {
let fixture_dir = std::path::Path::new("../client/tests/fixtures/msgpack");
assert!(
fixture_dir.exists(),
"Fixture directory not found: {}",
fixture_dir.display()
);
let mut count = 0;
for entry in fs::read_dir(fixture_dir).expect("read fixture dir") {
let entry = entry.expect("read dir entry");
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("msgpack") {
continue;
}
let name = path.file_stem().unwrap().to_str().unwrap().to_string();
let bytes = fs::read(&path).unwrap_or_else(|_| panic!("read fixture {}", name));
if name.starts_with("snapshot") {
rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
} else if name.starts_with("input_batch") {
rmp_serde::from_slice::<Vec<PlayerInput>>(&bytes)
.unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e));
} else if name.starts_with("input") {
rmp_serde::from_slice::<PlayerInput>(&bytes)
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
} else {
panic!("unknown fixture naming convention: {}", name);
}
count += 1;
}
assert!(count > 0, "no fixtures found");
eprintln!("Verified {} fixtures", count);
}
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
#[test]
fn all_entity_kind_variants_roundtrip() {