diff --git a/client/tests/fixtures/msgpack/malformed.msgpack b/client/tests/fixtures/msgpack/malformed.msgpack new file mode 100644 index 000000000..bd2e3507c --- /dev/null +++ b/client/tests/fixtures/msgpack/malformed.msgpack @@ -0,0 +1 @@ +‚¤ \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/player_input_interact.msgpack b/client/tests/fixtures/msgpack/player_input_interact.msgpack new file mode 100644 index 000000000..c2963fa57 --- /dev/null +++ b/client/tests/fixtures/msgpack/player_input_interact.msgpack @@ -0,0 +1 @@ +‚¤tick¦action¨Interact‚°target_entity_idc¤verb¤Talk \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/player_input_move.msgpack b/client/tests/fixtures/msgpack/player_input_move.msgpack new file mode 100644 index 000000000..2d733e444 --- /dev/null +++ b/client/tests/fixtures/msgpack/player_input_move.msgpack @@ -0,0 +1 @@ +‚¤tick¦action©MoveNorth \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack new file mode 100644 index 000000000..265612bdb Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack new file mode 100644 index 000000000..c633a2c48 Binary files /dev/null and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/test_ipc_fixtures.gd b/client/tests/test_ipc_fixtures.gd new file mode 100644 index 000000000..12594b279 --- /dev/null +++ b/client/tests/test_ipc_fixtures.gd @@ -0,0 +1,182 @@ +## D-030 Layer 1: Cross-language IPC fixture tests (#271) +## Validates that Protocol.gd decodes the #271 named fixtures identically to Rust. +## Fixtures generated by: cargo test --test gen_fixtures -- --ignored +## Rust validation: server/tests/serialization.rs (fixture_* tests) +class_name TestIpcFixtures +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().override_failure_message( + "Fixture not found: %s — run 'make fixtures' to regenerate" % path + ) + return file.get_buffer(file.get_length()) + + +# -- snapshot_minimal ---------------------------------------------------------- + +func test_fixture_snapshot_minimal_version() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) + + +func test_fixture_snapshot_minimal_tick() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.tick).is_equal(0) + + +func test_fixture_snapshot_minimal_entity_count() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.entities.size()).is_equal(1) + + +func test_fixture_snapshot_minimal_entity_kind() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + var entity = snapshot.entities[0] + assert_that(entity.entity_id).is_equal(1) + # entity.kind is {"variant": "Player", "data": null} from _decode_enum_variant + assert_that(entity.kind.variant).is_equal("Player") + + +func test_fixture_snapshot_minimal_no_monologue() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.current_monologue).is_null() + + +func test_fixture_snapshot_minimal_no_dialogue() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.dialogue_response).is_null() + + +# -- snapshot_full ------------------------------------------------------------- + +func test_fixture_snapshot_full_tick() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(42) + + +func test_fixture_snapshot_full_monologue_id() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.current_monologue).is_not_null() + assert_that(snapshot.current_monologue.id).is_equal("test_monologue_001") + + +func test_fixture_snapshot_full_monologue_text() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.current_monologue.text).is_equal("Something feels off about this place.") + + +func test_fixture_snapshot_full_dialogue_speaker() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.dialogue_response).is_not_null() + # dialogue_response has: line_id, text, speaker_entity_id (per protocol.gd v8 decode) + assert_that(snapshot.dialogue_response.speaker_entity_id).is_equal(99) + + +func test_fixture_snapshot_full_inventory() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.player_inventory.size()).is_equal(1) + assert_that(snapshot.player_inventory[0].name).is_equal("Forged Customs Cert") + + +func test_fixture_snapshot_full_poi_list() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.poi_list.size()).is_equal(1) + assert_that(snapshot.poi_list[0].poi_id).is_equal("docking_bay_7") + + +func test_fixture_snapshot_full_player_knowledge_entity() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.player_knowledge).is_not_null() + assert_that(snapshot.player_knowledge.entities.size()).is_equal(1) + assert_that(snapshot.player_knowledge.entities[0].name).is_equal("Kael") + + +func test_fixture_snapshot_full_player_knowledge_fact() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.player_knowledge.facts.size()).is_equal(1) + assert_that(snapshot.player_knowledge.facts[0].fact_id).is_equal("poi.docking_bay_7") + + +# -- player_input_move --------------------------------------------------------- + +func test_fixture_player_input_move_tick() -> void: + var bytes = _load_fixture("player_input_move") + var input = Protocol.decode_player_input(bytes) + assert_that(input).is_not_null() + assert_that(input.tick).is_equal(1) + + +func test_fixture_player_input_move_action() -> void: + var bytes = _load_fixture("player_input_move") + var input = Protocol.decode_player_input(bytes) + # action is {"variant": "MoveNorth", "data": null} from _decode_enum_variant + assert_that(input.action.variant).is_equal("MoveNorth") + + +# -- player_input_interact ----------------------------------------------------- + +func test_fixture_player_input_interact_tick() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + assert_that(input).is_not_null() + assert_that(input.tick).is_equal(2) + + +func test_fixture_player_input_interact_action() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + # Interact is a struct variant: {"variant": "Interact", "data": {"target_entity_id": 99, "verb": "Talk"}} + assert_that(input.action.variant).is_equal("Interact") + + +func test_fixture_player_input_interact_target() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + assert_that(input.action.data.target_entity_id).is_equal(99) + + +func test_fixture_player_input_interact_verb() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + assert_that(input.action.data.verb).is_equal("Talk") + + +# -- malformed ----------------------------------------------------------------- + +func test_fixture_malformed_snapshot_fails() -> void: + var bytes = _load_fixture("malformed") + # Intentionally truncated — decode_snapshot must return null (not crash) + var result = Protocol.decode_snapshot(bytes) + assert_that(result).is_null().override_failure_message( + "malformed fixture should not decode as a valid ObserverSnapshot" + ) + + +func test_fixture_malformed_input_fails() -> void: + var bytes = _load_fixture("malformed") + # Intentionally truncated — decode_player_input must return null (not crash) + var result = Protocol.decode_player_input(bytes) + assert_that(result).is_null().override_failure_message( + "malformed fixture should not decode as a valid PlayerInput" + ) diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index b32a4f1ae..78ce37016 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -2,6 +2,7 @@ //! Run with: cargo test --test gen_fixtures -- --ignored use settled_reach_server::bridge::types::*; +use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; use std::path::Path; @@ -41,7 +42,6 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -232,7 +232,6 @@ fn generate_msgpack_fixtures() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -287,6 +286,150 @@ fn generate_msgpack_fixtures() { write_fixture(name, &rmp_serde::to_vec_named(&input).unwrap()); } + // === #271 fixtures: named fixtures for cross-language Layer 1 testing === + + // snapshot_minimal: version=14, tick=0, one Player entity, all optionals absent + let snapshot_minimal = fixture_snapshot( + 0, + vec![VisibleEntity { + entity_id: 1, + x: 0.0, + y: 0.0, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }], + ); + write_fixture( + "snapshot_minimal", + &rmp_serde::to_vec_named(&snapshot_minimal).unwrap(), + ); + + // snapshot_full: version=14, tick=42, monologue + dialogue + inventory + POIs + KG dump + let snapshot_full = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick: 42, + game_time: GameTime { + day: 3, + time_of_day: 840, + day_phase: DayPhase::Evening, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::East, + player_stance: MovementStance::Walk, + player_inventory: vec![InventoryItem { + item_id: 7, + name: "Forged Customs Cert".to_string(), + slot: 0, + }], + entities: vec![VisibleEntity { + entity_id: 1, + x: 10.0, + y: 10.0, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: Some(MonologueEvent { + id: "test_monologue_001".to_string(), + text: "Something feels off about this place.".to_string(), + duration_seconds: 4.0, + }), + pending_recognitions: vec![PendingRecognitionWire { + entity_id: 7, + x: 12.0, + y: 8.0, + z: 0, + remaining_ticks: 3, + total_delay_ticks: 10, + }], + dialogue_response: Some(DialogueResponseEvent { + line_id: "kael_d_001".to_string(), + text: "We need to talk about the shipment.".to_string(), + speaker_entity_id: 99, + speaker_color_index: 2, + speaker_name: "Kael".to_string(), + }), + blocked_entities: vec![5, 6], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: Some(0xDEADBEEF), + poi_list: vec![PoiWire { + poi_id: "docking_bay_7".to_string(), + name: "Docking Bay 7".to_string(), + x: 50, + y: 30, + z: 0, + category: PoiCategory::Location, + }], + examine_result: Some(ExamineResultWire { + entity_id: 42, + text: "A smuggler, probably. The way they hold themselves.".to_string(), + confidence: KnowledgeConfidence::KnowsOf, + }), + player_knowledge: Some(PlayerKnowledgeWire { + entities: vec![KnownEntityWire { + entity_id: 99, + name: "Kael".to_string(), + confidence: KnowledgeConfidence::KnowsDetails, + source: "DirectObservation".to_string(), + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + last_observed_tick: 40, + }], + facts: vec![KnownFactWire { + fact_id: "poi.docking_bay_7".to_string(), + confidence: KnowledgeConfidence::KnowsOf, + source: "DirectObservation".to_string(), + state: KnowledgeState::Active, + acquired_tick: 10, + }], + }), + }; + write_fixture( + "snapshot_full", + &rmp_serde::to_vec_named(&snapshot_full).unwrap(), + ); + + // player_input_move: tick=1, MoveNorth + let input_move = PlayerInput { + tick: 1, + action: PlayerAction::MoveNorth, + }; + write_fixture( + "player_input_move", + &rmp_serde::to_vec_named(&input_move).unwrap(), + ); + + // player_input_interact: tick=2, Interact { target: 99, verb: "Talk" } + let input_interact = PlayerInput { + tick: 2, + action: PlayerAction::Interact { + target_entity_id: Some(99), + verb: Some("Talk".to_string()), + }, + }; + write_fixture( + "player_input_interact", + &rmp_serde::to_vec_named(&input_interact).unwrap(), + ); + + // malformed: intentionally truncated bytes — tests error handling in both Rust and GDScript + // 0x82 = fixmap with 2 entries, 0xa4 = fixstr of length 4 — incomplete map, no key/value follows + write_fixture("malformed", &[0x82u8, 0xa4u8]); + // === Boundary value fixtures (#472) === // 14 raw integer values at encoding format boundaries (Appendix C). // These are Rust-encoded MessagePack that GDScript must decode correctly. diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 91d0c6bf4..58e5b9dec 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -30,7 +30,6 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -174,13 +173,15 @@ fn all_fixtures_deserialize() { } else if name.starts_with("input_batch") { rmp_serde::from_slice::>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); - } else if name.starts_with("input") { + } else if name.starts_with("input") || name.starts_with("player_input") { rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e)); } else if name.starts_with("boundary_raw") { // Raw integer boundary fixtures (#472): single u64 values rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e)); + } else if name == "malformed" { + // Intentionally truncated — skip deserialization check, error handling tested elsewhere } else { panic!( "unknown fixture naming convention: {} — add a deserialization branch for this prefix", @@ -280,7 +281,6 @@ fn snapshot_v2_fields_roundtrip() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -384,7 +384,6 @@ fn all_facing_direction_variants_roundtrip() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -1394,7 +1393,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { // `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc. // are all `#[serde(default)]` — they must default to None/empty when absent. let minimal_json = serde_json::json!({ - "version": 14, + "version": 13, "tick": 42, "game_time": { "day": 0, @@ -1642,3 +1641,142 @@ fn nearby_interaction_object_type_roundtrip() { Some(ObjectType::Container) ); } + +// ============================================================ +// #271: Named fixture validation tests (D-030 Layer 1) +// +// These tests read the committed .msgpack files and assert specific field +// values. They serve as the Rust side of cross-language verification — the +// same fixtures are decoded by client/tests/test_ipc_fixtures.gd. +// ============================================================ + +fn read_named_fixture(name: &str) -> Vec { + let path = format!("../client/tests/fixtures/msgpack/{}.msgpack", name); + fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture '{}': {}", name, e)) +} + +#[test] +fn fixture_snapshot_minimal_fields() { + let bytes = read_named_fixture("snapshot_minimal"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal"); + + assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); + assert_eq!(snap.tick, 0, "tick should be 0"); + assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity"); + assert_eq!(snap.entities[0].entity_id, 1); + assert!( + matches!(snap.entities[0].kind, EntityKind::Player), + "entity should be Player kind" + ); + assert!(snap.current_monologue.is_none(), "no monologue in minimal"); + assert!(snap.dialogue_response.is_none(), "no dialogue in minimal"); + assert!(snap.player_inventory.is_empty(), "no inventory in minimal"); + assert!(snap.poi_list.is_empty(), "no POIs in minimal"); + assert!(snap.player_knowledge.is_none(), "no KG in minimal"); +} + +#[test] +fn fixture_snapshot_full_fields() { + let bytes = read_named_fixture("snapshot_full"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full"); + + assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); + assert_eq!(snap.tick, 42, "tick should be 42"); + + // Monologue + let monologue = snap.current_monologue.as_ref().expect("monologue absent"); + assert_eq!(monologue.id, "test_monologue_001"); + assert_eq!( + monologue.text, + "Something feels off about this place." + ); + + // Dialogue + let dialogue = snap.dialogue_response.as_ref().expect("dialogue absent"); + assert_eq!(dialogue.speaker_entity_id, 99); + assert_eq!(dialogue.speaker_name, "Kael"); + + // Inventory + assert_eq!(snap.player_inventory.len(), 1); + assert_eq!(snap.player_inventory[0].name, "Forged Customs Cert"); + + // POIs + assert_eq!(snap.poi_list.len(), 1); + assert_eq!(snap.poi_list[0].poi_id, "docking_bay_7"); + + // Examine result + let examine = snap.examine_result.as_ref().expect("examine_result absent"); + assert_eq!(examine.entity_id, 42); + + // Player knowledge + let kg = snap.player_knowledge.as_ref().expect("player_knowledge absent"); + assert_eq!(kg.entities.len(), 1); + assert_eq!(kg.entities[0].name, "Kael"); + assert_eq!(kg.facts.len(), 1); + assert_eq!(kg.facts[0].fact_id, "poi.docking_bay_7"); + + // RNG seed + assert_eq!(snap.rng_seed, Some(0xDEADBEEF)); + + // Pending recognitions + assert_eq!(snap.pending_recognitions.len(), 1); + assert_eq!(snap.pending_recognitions[0].entity_id, 7); + + // Blocked entities + assert_eq!(snap.blocked_entities, vec![5u64, 6]); +} + +#[test] +fn fixture_player_input_move_fields() { + let bytes = read_named_fixture("player_input_move"); + let input: PlayerInput = + rmp_serde::from_slice(&bytes).expect("deserialize player_input_move"); + + assert_eq!(input.tick, 1, "tick should be 1"); + assert!( + matches!(input.action, PlayerAction::MoveNorth), + "action should be MoveNorth" + ); +} + +#[test] +fn fixture_player_input_interact_fields() { + let bytes = read_named_fixture("player_input_interact"); + let input: PlayerInput = + rmp_serde::from_slice(&bytes).expect("deserialize player_input_interact"); + + assert_eq!(input.tick, 2, "tick should be 2"); + match &input.action { + PlayerAction::Interact { + target_entity_id, + verb, + } => { + assert_eq!(*target_entity_id, Some(99u64), "target_entity_id should be Some(99)"); + assert_eq!( + verb.as_deref(), + Some("Talk"), + "verb should be Some(\"Talk\")" + ); + } + other => panic!("expected Interact, got {:?}", other), + } +} + +#[test] +fn fixture_malformed_fails_deserialization() { + let bytes = read_named_fixture("malformed"); + // Intentionally truncated — must NOT deserialize as ObserverSnapshot + let result = rmp_serde::from_slice::(&bytes); + assert!( + result.is_err(), + "malformed fixture should fail to deserialize as ObserverSnapshot" + ); + // Also must NOT deserialize as PlayerInput + let result2 = rmp_serde::from_slice::(&bytes); + assert!( + result2.is_err(), + "malformed fixture should fail to deserialize as PlayerInput" + ); +}