diff --git a/Makefile b/Makefile index 781235427..c1491d875 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) decisions-sync decisions-coverage decisions-active decisions-orphan \ db-backup db-install validate-content content-ron check-fact-ids setup-hooks \ pre-pr pre-pr-lint pre-pr-build pre-pr-test pre-pr-validate pre-pr-fixtures \ - pre-pr-server pre-pr-client pre-pr-content + pre-pr-server pre-pr-client pre-pr-content \ + fixtures-client # --- Configuration --- @@ -37,6 +38,7 @@ help: @echo " make validate-content Validate content YAML against schemas" @echo " make check-fact-ids Check fact_id references against knowledge catalogs" @echo " make content-ron Convert content YAML to RON (build-time)" + @echo " make fixtures-client Generate GDScript->Rust cross-encoder fixtures (#475)" @echo "" @echo " make pre-pr Run all pre-PR checks (lint, build, test, validate, fixtures)" @echo " make pre-pr-server Server-scoped pre-PR (lint, build, test, fixtures)" @@ -111,6 +113,14 @@ test-server: fixtures: cd server && cargo test --test gen_fixtures -- --ignored +fixtures-client: + @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } + @echo "Generating GDScript fixtures for Rust decoder..." + $(GODOT) --headless --path client -s res://tests/gen_client_fixtures.gd + @echo "Verifying Rust can decode GDScript fixtures..." + cd server && cargo test --test serialization gdscript_generated_fixtures + @echo "--- Fixtures-client: PASS ---" + 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/ diff --git a/client/tests/gen_client_fixtures.gd b/client/tests/gen_client_fixtures.gd new file mode 100644 index 000000000..f3bbf6df1 --- /dev/null +++ b/client/tests/gen_client_fixtures.gd @@ -0,0 +1,144 @@ +## Generate MessagePack fixtures from GDScript encoder for Rust decoder testing. +## Run with: make fixtures-client +## (or: godot --headless --path client -s res://tests/gen_client_fixtures.gd) +## +## Reverse direction of server/tests/gen_fixtures.rs: GDScript encodes -> Rust decodes. +## Together with the Rust-generated fixtures, this closes the cross-encoder +## compatibility loop (D-030 Layer 1, #475). +## +## Loads Messagepack encoder directly (not via class_name) because -s scripts +## run before the project's class_name registry is fully populated. +extends SceneTree + +var _Msgpack: GDScript +var _count := 0 +var _output_dir: String + + +func _init(): + _run.call_deferred() + + +func _run(): + _Msgpack = load("res://addons/messagepack/messagepack.gd") + + var project_root := ProjectSettings.globalize_path("res://") + var repo_root := project_root.rstrip("/").get_base_dir() + _output_dir = repo_root.path_join("server/tests/fixtures/gdscript") + + DirAccess.make_dir_recursive_absolute(_output_dir) + + _generate_inputs() + _generate_boundary_inputs() + _generate_batch() + + print("Generated %d GDScript fixtures at %s" % [_count, _output_dir]) + quit() + + +func _write_fixture(name: String, bytes: PackedByteArray) -> void: + var path := _output_dir.path_join(name + ".msgpack") + var file := FileAccess.open(path, FileAccess.WRITE) + if file == null: + push_error("Failed to write fixture: %s (error: %d)" % [path, FileAccess.get_open_error()]) + return + file.store_buffer(bytes) + file.close() + print(" Wrote %s (%d bytes)" % [name, bytes.size()]) + _count += 1 + + +## Encode a single PlayerInput to MessagePack bytes. +## Mirrors Protocol.encode_player_input() from protocol.gd. +func _encode_input(tick: int, action_name: String, action_data: Variant = null) -> PackedByteArray: + var action: Variant + if action_data != null: + action = {action_name: action_data} + elif action_name == "Interact": + action = {"Interact": {"target_entity_id": null, "verb": null}} + else: + action = action_name + + var result = _Msgpack.encode({"tick": tick, "action": action}) + if result.status != null: + push_error("Encode failed: %s" % result.status) + return PackedByteArray() + return result.value + + +## Encode an array of PlayerInputs to MessagePack bytes. +## Mirrors Protocol.encode_player_inputs() from protocol.gd. +func _encode_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: Variant + if action_data != null: + action = {action_name: action_data} + elif action_name == "Interact": + action = {"Interact": {"target_entity_id": null, "verb": null}} + else: + action = action_name + wire_inputs.append({"tick": input["tick"], "action": action}) + + var result = _Msgpack.encode(wire_inputs) + if result.status != null: + push_error("Batch encode failed: %s" % result.status) + return PackedByteArray() + return result.value + + +func _generate_inputs() -> void: + # Unit variants: movement directions (tick=100) + _write_fixture("input_move_north", + _encode_input(100, "MoveNorth")) + for dir_name in ["MoveNortheast", "MoveSoutheast", "MoveSouthwest", "MoveNorthwest"]: + _write_fixture("input_%s" % dir_name.to_snake_case(), + _encode_input(100, dir_name)) + + # Data variant: UsePerceptionMode (tick=200) + _write_fixture("input_perception_mode", + _encode_input(200, "UsePerceptionMode", "thermal")) + + # Struct variant: Interact with null fields (tick=100) + _write_fixture("input_interact", + _encode_input(100, "Interact")) + + # Other unit variants + _write_fixture("input_pause", + _encode_input(100, "Pause")) + _write_fixture("input_toggle_stance_up", + _encode_input(100, "ToggleStanceUp")) + + +func _generate_boundary_inputs() -> void: + # Tick values at MessagePack encoding format boundaries. + # GDScript encodes 256-32767 as int_16 (0xd1); Rust encodes as uint_16 (0xcd). + # GDScript encodes 65536-2147483647 as int_32 (0xd2); Rust as uint_32 (0xce). + # Both are valid MessagePack. Rust's rmp_serde must accept both. + var boundary_ticks: Array = [ + [0, "boundary_tick_0"], + [127, "boundary_tick_127"], + [128, "boundary_tick_128"], + [255, "boundary_tick_255"], + [256, "boundary_tick_256"], # int_16 asymmetry start + [32767, "boundary_tick_32767"], # int_16 asymmetry end + [32768, "boundary_tick_32768"], + [65535, "boundary_tick_65535"], + [65536, "boundary_tick_65536"], # int_32 asymmetry start + [2147483647, "boundary_tick_2147483647"], # int_32 asymmetry end + ] + + for pair in boundary_ticks: + _write_fixture(pair[1], + _encode_input(pair[0], "Pause")) + + +func _generate_batch() -> void: + # Vec with two actions (mirrors Rust input_batch_two fixture) + _write_fixture("input_batch_two", + _encode_inputs([ + {"tick": 0, "action_name": "MoveNorth"}, + {"tick": 0, "action_name": "Interact"}, + ])) diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 9a400fdb0..56c47d6c7 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -69,6 +69,19 @@ make test-client # gdUnit4 tests (headless runner pending) Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030). +### Cross-Encoder Fixtures + +```bash +make fixtures # Regenerate Rust->GDScript fixtures (server/tests/gen_fixtures.rs) +make fixtures-client # Generate GDScript->Rust fixtures + verify Rust decoder (#475) +``` + +The bidirectional protocol is validated by two sets of committed fixtures: +- **Rust encodes, GDScript decodes:** `client/tests/fixtures/msgpack/` (generated by `make fixtures`) +- **GDScript encodes, Rust decodes:** `server/tests/fixtures/gdscript/` (generated by `make fixtures-client`) + +Regenerate both after any protocol change. Commit the updated fixtures alongside the code change. + ### Lint ```bash diff --git a/server/tests/fixtures/gdscript/boundary_tick_0.msgpack b/server/tests/fixtures/gdscript/boundary_tick_0.msgpack new file mode 100644 index 000000000..15d65cd6d Binary files /dev/null and b/server/tests/fixtures/gdscript/boundary_tick_0.msgpack differ diff --git a/server/tests/fixtures/gdscript/boundary_tick_127.msgpack b/server/tests/fixtures/gdscript/boundary_tick_127.msgpack new file mode 100644 index 000000000..6e1132226 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_127.msgpack @@ -0,0 +1 @@ +‚¤tick¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_128.msgpack b/server/tests/fixtures/gdscript/boundary_tick_128.msgpack new file mode 100644 index 000000000..f58d4c964 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_128.msgpack @@ -0,0 +1 @@ +‚¤tickÌ€¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack b/server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack new file mode 100644 index 000000000..d5e340907 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_2147483647.msgpack @@ -0,0 +1 @@ +‚¤tickÒÿÿÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_255.msgpack b/server/tests/fixtures/gdscript/boundary_tick_255.msgpack new file mode 100644 index 000000000..89c76131c --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_255.msgpack @@ -0,0 +1 @@ +‚¤tickÌÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_256.msgpack b/server/tests/fixtures/gdscript/boundary_tick_256.msgpack new file mode 100644 index 000000000..a587bee7a Binary files /dev/null and b/server/tests/fixtures/gdscript/boundary_tick_256.msgpack differ diff --git a/server/tests/fixtures/gdscript/boundary_tick_32767.msgpack b/server/tests/fixtures/gdscript/boundary_tick_32767.msgpack new file mode 100644 index 000000000..54bc069e3 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_32767.msgpack @@ -0,0 +1 @@ +‚¤tickÑÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_32768.msgpack b/server/tests/fixtures/gdscript/boundary_tick_32768.msgpack new file mode 100644 index 000000000..df76b2742 Binary files /dev/null and b/server/tests/fixtures/gdscript/boundary_tick_32768.msgpack differ diff --git a/server/tests/fixtures/gdscript/boundary_tick_65535.msgpack b/server/tests/fixtures/gdscript/boundary_tick_65535.msgpack new file mode 100644 index 000000000..9984b34e8 --- /dev/null +++ b/server/tests/fixtures/gdscript/boundary_tick_65535.msgpack @@ -0,0 +1 @@ +‚¤tickÍÿÿ¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/boundary_tick_65536.msgpack b/server/tests/fixtures/gdscript/boundary_tick_65536.msgpack new file mode 100644 index 000000000..b53bad5ef Binary files /dev/null and b/server/tests/fixtures/gdscript/boundary_tick_65536.msgpack differ diff --git a/server/tests/fixtures/gdscript/input_batch_two.msgpack b/server/tests/fixtures/gdscript/input_batch_two.msgpack new file mode 100644 index 000000000..895f9f080 Binary files /dev/null and b/server/tests/fixtures/gdscript/input_batch_two.msgpack differ diff --git a/server/tests/fixtures/gdscript/input_interact.msgpack b/server/tests/fixtures/gdscript/input_interact.msgpack new file mode 100644 index 000000000..0444a6588 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_interact.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action¨Interact‚°target_entity_idÀ¤verbÀ \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_move_north.msgpack b/server/tests/fixtures/gdscript/input_move_north.msgpack new file mode 100644 index 000000000..937f385e1 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_move_north.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action©MoveNorth \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_move_northeast.msgpack b/server/tests/fixtures/gdscript/input_move_northeast.msgpack new file mode 100644 index 000000000..a0786efcc --- /dev/null +++ b/server/tests/fixtures/gdscript/input_move_northeast.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action­MoveNortheast \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_move_northwest.msgpack b/server/tests/fixtures/gdscript/input_move_northwest.msgpack new file mode 100644 index 000000000..98b549ef4 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_move_northwest.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action­MoveNorthwest \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_move_southeast.msgpack b/server/tests/fixtures/gdscript/input_move_southeast.msgpack new file mode 100644 index 000000000..33145e1d4 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_move_southeast.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action­MoveSoutheast \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_move_southwest.msgpack b/server/tests/fixtures/gdscript/input_move_southwest.msgpack new file mode 100644 index 000000000..7500a3343 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_move_southwest.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action­MoveSouthwest \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_pause.msgpack b/server/tests/fixtures/gdscript/input_pause.msgpack new file mode 100644 index 000000000..e01710cb2 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_pause.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action¥Pause \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_perception_mode.msgpack b/server/tests/fixtures/gdscript/input_perception_mode.msgpack new file mode 100644 index 000000000..3f922c7e3 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_perception_mode.msgpack @@ -0,0 +1 @@ +‚¤tickÌȦaction±UsePerceptionMode§thermal \ No newline at end of file diff --git a/server/tests/fixtures/gdscript/input_toggle_stance_up.msgpack b/server/tests/fixtures/gdscript/input_toggle_stance_up.msgpack new file mode 100644 index 000000000..84c60b602 --- /dev/null +++ b/server/tests/fixtures/gdscript/input_toggle_stance_up.msgpack @@ -0,0 +1 @@ +‚¤tickd¦action®ToggleStanceUp \ No newline at end of file diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 7efdae655..3366279fe 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -1004,6 +1004,100 @@ fn malformed_input_in_batch_rejects_entire_batch() { ); } +/// GDScript-generated fixtures must deserialize correctly (D-030 Layer 1, #475). +/// Validates the reverse direction: GDScript encoder -> Rust decoder. +/// Together with all_fixtures_deserialize (Rust -> GDScript), this closes the +/// cross-encoder compatibility loop. +/// +/// Fixtures generated by: make fixtures-client +/// (runs client/tests/gen_client_fixtures.gd via Godot headless) +#[test] +fn gdscript_generated_fixtures_deserialize() { + let fixture_dir = std::path::Path::new("tests/fixtures/gdscript"); + + if !fixture_dir.exists() { + eprintln!( + "SKIP: GDScript fixtures not found at {}. Run `make fixtures-client` to generate.", + fixture_dir.display() + ); + return; + } + + let mut count = 0; + for entry in fs::read_dir(&fixture_dir).expect("read gdscript 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("input_batch") { + let inputs: Vec = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| { + panic!("deserialize GDScript batch fixture {}: {}", name, e) + }); + assert!( + !inputs.is_empty(), + "batch fixture {} should not be empty", + name + ); + } else if name.starts_with("input_") || name.starts_with("boundary_tick_") { + let input: PlayerInput = rmp_serde::from_slice(&bytes).unwrap_or_else(|e| { + panic!("deserialize GDScript input fixture {}: {}", name, e) + }); + // Verify specific fixtures for extra confidence + match name.as_str() { + "input_move_north" => { + assert_eq!(input.tick, 100); + assert!(matches!(input.action, PlayerAction::MoveNorth)); + } + "input_perception_mode" => { + assert_eq!(input.tick, 200); + assert!( + matches!(input.action, PlayerAction::UsePerceptionMode(ref s) if s == "thermal") + ); + } + "input_interact" => { + assert_eq!(input.tick, 100); + assert!(matches!(input.action, PlayerAction::Interact { .. })); + } + "boundary_tick_256" => { + assert_eq!(input.tick, 256, "int_16 asymmetry: tick=256"); + } + "boundary_tick_32767" => { + assert_eq!(input.tick, 32767, "int_16 asymmetry: tick=32767"); + } + "boundary_tick_65536" => { + assert_eq!(input.tick, 65536, "int_32 asymmetry: tick=65536"); + } + "boundary_tick_2147483647" => { + assert_eq!( + input.tick, 2147483647, + "int_32 asymmetry: tick=2^31-1" + ); + } + _ => {} // Other fixtures: deserialization success is sufficient + } + } else { + panic!( + "unknown GDScript fixture naming convention: {} — add a deserialization branch", + name + ); + } + count += 1; + } + + if count == 0 { + eprintln!( + "SKIP: no .msgpack files found in {}. Run `make fixtures-client` to generate.", + fixture_dir.display() + ); + return; + } + eprintln!("Verified {} GDScript-generated fixtures", count); +} + /// NearbyInteraction.object_type round-trips through MessagePack (#422). /// Verifies object_type=Some(Container) survives the wire. #[test]