Blockers (4): - Wire cargo deny check into pre-pr-server (was dead config) (#1) - ConfirmBookmark idempotency guard: SimError ProtocolError on retry (#2) - D-080 amendment: transfer_npc_knowledge retained-but-dormant honest doc (#3) - SelectedBookmark v0.2 transient scope; save/load deferred to #863 (#4) Issues (8): - ConfirmBookmark validation tests: unknown id, invalid location, valid path, double-confirm guard (#5) - snapshot_with_bookmark_catalog fixture for client #618 decode tests (#6) - generate_brands: replace 5 raw .unwrap() with eprintln+exit pattern (#7) - npc_knowledge_transfer.rs: stale run_npc_conversations refs cleaned (#8) - monologue.rs: residual D-078 "overheard conversations" doc removed (#9) - 5 test files: orphan blank lines from removed conversation_* fields (#10) - BookmarkCatalog: add PartialEq, Eq derives (matches sibling) (#11) Nits (2): - culture_tag doc: describe BookmarkRegistry::build_catalog behavior, remove "until #679 lands" placeholder (#13) - generate_brands seed=1 canonical comment (#14) Follow-ups filed: - #862 — BookmarkPlugin::new(registry) injection (#12 deferred) - #863 — Wire SelectedBookmark into SaveState (Sprint 37) Pre-pr-server: fmt clean, clippy clean, deny clean, build clean. nextest save_io failures pre-existing parallelism issue (sequential cargo test --lib passes 20/20). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1811 lines
63 KiB
Rust
1811 lines
63 KiB
Rust
//! IPC serialization round-trip tests (D-030 Layer 1: fixture-based).
|
|
|
|
use settled_reach_server::bridge::types::*;
|
|
use settled_reach_server::simulation::time::{DayPhase, TickRate};
|
|
use std::fs;
|
|
|
|
/// Helper to create a minimal v2 snapshot for tests
|
|
fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
|
ObserverSnapshot {
|
|
version: PROTOCOL_VERSION,
|
|
tick,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: FacingDirection::North,
|
|
player_stance: MovementStance::default(),
|
|
player_inventory: vec![],
|
|
entities,
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
pending_recognitions: vec![],
|
|
dialogue_response: None,
|
|
blocked_entities: vec![],
|
|
scan_events: vec![],
|
|
sound_events: vec![],
|
|
follow_state: None,
|
|
character_pressure: None,
|
|
rng_seed: None,
|
|
poi_list: vec![],
|
|
examine_result: None,
|
|
player_knowledge: None,
|
|
save_result: None,
|
|
triangle_crisis_events: vec![],
|
|
state_hash: None,
|
|
debug_response: None,
|
|
sim_errors: vec![],
|
|
current_ticker: None,
|
|
settings_response: None,
|
|
economy_snapshot: None,
|
|
bookmark_catalog: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn observer_snapshot_roundtrip() {
|
|
let snapshot = test_snapshot(
|
|
42,
|
|
vec![VisibleEntity {
|
|
entity_id: 1,
|
|
x: 10.0,
|
|
y: 20.0,
|
|
z: 0,
|
|
kind: EntityKind::Npc,
|
|
visibility: VisibilitySector::Forward,
|
|
relationship: RelationshipState::Unknown,
|
|
observation: EntityVisibility::Visible,
|
|
tell_state: None,
|
|
}],
|
|
);
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.version, PROTOCOL_VERSION);
|
|
assert_eq!(decoded.tick, 42);
|
|
assert_eq!(decoded.entities.len(), 1);
|
|
assert_eq!(decoded.entities[0].entity_id, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn player_input_roundtrip() {
|
|
let input = PlayerInput {
|
|
tick: 100,
|
|
action: PlayerAction::MoveNorth,
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
|
|
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.tick, 100);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_snapshot_roundtrip() {
|
|
let snapshot = test_snapshot(0, vec![]);
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.tick, 0);
|
|
assert!(decoded.entities.is_empty());
|
|
}
|
|
|
|
/// All PlayerAction variants must survive MessagePack round-trip (D-030 Layer 1)
|
|
#[test]
|
|
fn all_player_action_variants_roundtrip() {
|
|
let actions = vec![
|
|
PlayerAction::MoveNorth,
|
|
PlayerAction::MoveSouth,
|
|
PlayerAction::MoveEast,
|
|
PlayerAction::MoveWest,
|
|
PlayerAction::MoveNortheast,
|
|
PlayerAction::MoveNorthwest,
|
|
PlayerAction::MoveSoutheast,
|
|
PlayerAction::MoveSouthwest,
|
|
PlayerAction::Interact {
|
|
target_entity_id: None,
|
|
verb: None,
|
|
},
|
|
PlayerAction::UsePerceptionMode("thermal".to_string()),
|
|
PlayerAction::Pause,
|
|
PlayerAction::Unpause,
|
|
PlayerAction::SetTickRate(TickRate::Half),
|
|
PlayerAction::ToggleStanceUp,
|
|
PlayerAction::ToggleStanceDown,
|
|
PlayerAction::WalkAway,
|
|
PlayerAction::SetFacing {
|
|
facing: "north".to_string(),
|
|
},
|
|
PlayerAction::TeleportToHub,
|
|
PlayerAction::DialogueResponse {
|
|
target_entity_id: 42,
|
|
response_id: "kael-davan_d_001".to_string(),
|
|
},
|
|
PlayerAction::SaveGame {
|
|
path: "/tmp/test.msgpack".to_string(),
|
|
},
|
|
PlayerAction::LoadGame {
|
|
path: "/tmp/test.msgpack".to_string(),
|
|
},
|
|
];
|
|
|
|
for action in actions {
|
|
let input = PlayerInput {
|
|
tick: 1,
|
|
action: action.clone(),
|
|
};
|
|
let bytes = rmp_serde::to_vec_named(&input).expect("serialize");
|
|
let decoded: PlayerInput = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(decoded.tick, 1);
|
|
// Verify the variant survived by re-serializing and comparing bytes
|
|
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
|
assert_eq!(bytes, re_bytes, "round-trip mismatch for action variant");
|
|
}
|
|
}
|
|
|
|
/// 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_boundary") {
|
|
// Boundary snapshot fixtures (#472): tick may exceed PROTOCOL_VERSION check
|
|
rmp_serde::from_slice::<ObserverSnapshot>(&bytes).unwrap_or_else(|e| {
|
|
panic!("deserialize boundary snapshot fixture {}: {}", name, e)
|
|
});
|
|
} else if name.starts_with("snapshot") {
|
|
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
|
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
|
assert_eq!(
|
|
snap.version, PROTOCOL_VERSION,
|
|
"fixture {} has wrong version",
|
|
name
|
|
);
|
|
} 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") || name.starts_with("player_input") {
|
|
rmp_serde::from_slice::<PlayerInput>(&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::<u64>(&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",
|
|
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() {
|
|
let kinds = vec![
|
|
EntityKind::Player,
|
|
EntityKind::Npc,
|
|
EntityKind::Object,
|
|
EntityKind::Terrain,
|
|
];
|
|
|
|
for (i, kind) in kinds.into_iter().enumerate() {
|
|
let entity = VisibleEntity {
|
|
entity_id: i as u64,
|
|
x: 0.0,
|
|
y: 0.0,
|
|
z: 0,
|
|
kind,
|
|
visibility: VisibilitySector::Forward,
|
|
relationship: RelationshipState::Unknown,
|
|
observation: EntityVisibility::Visible,
|
|
tell_state: None,
|
|
};
|
|
let snapshot = test_snapshot(0, vec![entity]);
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
let re_bytes = rmp_serde::to_vec_named(&decoded).expect("re-serialize");
|
|
assert_eq!(
|
|
bytes, re_bytes,
|
|
"round-trip mismatch for EntityKind variant"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// v2 snapshot fields round-trip correctly
|
|
#[test]
|
|
fn snapshot_v2_fields_roundtrip() {
|
|
let snapshot = ObserverSnapshot {
|
|
version: PROTOCOL_VERSION,
|
|
tick: 100,
|
|
game_time: GameTime {
|
|
day: 3,
|
|
time_of_day: 720,
|
|
day_phase: DayPhase::Evening,
|
|
tick_rate: TickRate::Paused,
|
|
},
|
|
player_facing: FacingDirection::Southeast,
|
|
player_stance: MovementStance::default(),
|
|
player_inventory: vec![],
|
|
entities: vec![VisibleEntity {
|
|
entity_id: 1,
|
|
x: 5.5,
|
|
y: 10.5,
|
|
z: 0,
|
|
kind: EntityKind::Player,
|
|
visibility: VisibilitySector::Forward,
|
|
relationship: RelationshipState::Unknown,
|
|
observation: EntityVisibility::Visible,
|
|
tell_state: None,
|
|
}],
|
|
visible_tiles: vec![
|
|
VisibleTile {
|
|
x: 5,
|
|
y: 10,
|
|
z: 0,
|
|
visibility: VisibilitySector::Forward,
|
|
tile_kind: TileKind::Floor,
|
|
zone_id: None,
|
|
},
|
|
VisibleTile {
|
|
x: 6,
|
|
y: 10,
|
|
z: 0,
|
|
visibility: VisibilitySector::Peripheral,
|
|
tile_kind: TileKind::Wall,
|
|
zone_id: None,
|
|
},
|
|
],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
pending_recognitions: vec![],
|
|
dialogue_response: None,
|
|
blocked_entities: vec![],
|
|
scan_events: vec![],
|
|
sound_events: vec![],
|
|
follow_state: None,
|
|
character_pressure: None,
|
|
rng_seed: None,
|
|
poi_list: vec![],
|
|
examine_result: None,
|
|
player_knowledge: None,
|
|
save_result: None,
|
|
triangle_crisis_events: vec![],
|
|
state_hash: None,
|
|
debug_response: None,
|
|
sim_errors: vec![],
|
|
current_ticker: None,
|
|
settings_response: None,
|
|
economy_snapshot: None,
|
|
bookmark_catalog: None,
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.version, PROTOCOL_VERSION);
|
|
assert_eq!(decoded.game_time.day, 3);
|
|
assert_eq!(decoded.game_time.time_of_day, 720);
|
|
assert_eq!(decoded.game_time.day_phase, DayPhase::Evening);
|
|
assert_eq!(decoded.game_time.tick_rate, TickRate::Paused);
|
|
assert_eq!(decoded.player_facing, FacingDirection::Southeast);
|
|
assert_eq!(decoded.visible_tiles.len(), 2);
|
|
assert_eq!(
|
|
decoded.visible_tiles[0].visibility,
|
|
VisibilitySector::Forward
|
|
);
|
|
assert_eq!(
|
|
decoded.visible_tiles[1].visibility,
|
|
VisibilitySector::Peripheral
|
|
);
|
|
assert_eq!(decoded.entities[0].visibility, VisibilitySector::Forward);
|
|
}
|
|
|
|
/// Entity::to_bits() must roundtrip through from_bits() — guards against
|
|
/// bevy version changes silently breaking wire IDs (Hoshe #12).
|
|
#[test]
|
|
fn entity_to_bits_roundtrip() {
|
|
use bevy_ecs::entity::Entity;
|
|
// Create entities via a World so we get valid index+generation pairs
|
|
let mut world = bevy_ecs::world::World::new();
|
|
let e1 = world.spawn_empty().id();
|
|
let e2 = world.spawn_empty().id();
|
|
let e3 = world.spawn_empty().id();
|
|
// Despawn and respawn to get a higher generation
|
|
world.despawn(e2);
|
|
let e4 = world.spawn_empty().id();
|
|
|
|
for entity in [e1, e2, e3, e4] {
|
|
let bits = entity.to_bits();
|
|
let restored = Entity::from_bits(bits);
|
|
assert_eq!(
|
|
entity, restored,
|
|
"Entity::to_bits() roundtrip failed for {:?}",
|
|
entity
|
|
);
|
|
}
|
|
}
|
|
|
|
/// PROTOCOL_VERSION constant matches snapshot version field
|
|
#[test]
|
|
fn protocol_version_constant_matches_snapshot() {
|
|
let snapshot = test_snapshot(0, vec![]);
|
|
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
|
assert_eq!(
|
|
PROTOCOL_VERSION, 21,
|
|
"bump this assertion when protocol version changes"
|
|
);
|
|
}
|
|
|
|
/// All FacingDirection variants round-trip
|
|
#[test]
|
|
fn all_facing_direction_variants_roundtrip() {
|
|
let directions = [
|
|
FacingDirection::North,
|
|
FacingDirection::Northeast,
|
|
FacingDirection::East,
|
|
FacingDirection::Southeast,
|
|
FacingDirection::South,
|
|
FacingDirection::Southwest,
|
|
FacingDirection::West,
|
|
FacingDirection::Northwest,
|
|
];
|
|
|
|
for dir in directions {
|
|
let snapshot = ObserverSnapshot {
|
|
version: PROTOCOL_VERSION,
|
|
tick: 0,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: dir,
|
|
player_stance: MovementStance::default(),
|
|
player_inventory: vec![],
|
|
entities: vec![],
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
pending_recognitions: vec![],
|
|
dialogue_response: None,
|
|
blocked_entities: vec![],
|
|
scan_events: vec![],
|
|
sound_events: vec![],
|
|
|
|
follow_state: None,
|
|
character_pressure: None,
|
|
rng_seed: None,
|
|
poi_list: vec![],
|
|
examine_result: None,
|
|
player_knowledge: None,
|
|
save_result: None,
|
|
triangle_crisis_events: vec![],
|
|
state_hash: None,
|
|
debug_response: None,
|
|
sim_errors: vec![],
|
|
current_ticker: None,
|
|
settings_response: None,
|
|
economy_snapshot: None,
|
|
bookmark_catalog: None,
|
|
};
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(decoded.player_facing, dir);
|
|
}
|
|
}
|
|
|
|
/// v6 fields: all MovementStance variants round-trip (#449, D-053)
|
|
#[test]
|
|
fn all_movement_stance_variants_roundtrip() {
|
|
let stances = [
|
|
MovementStance::Sprint,
|
|
MovementStance::Walk,
|
|
MovementStance::Careful,
|
|
MovementStance::Crouch,
|
|
];
|
|
|
|
for stance in stances {
|
|
let snapshot = test_snapshot(0, vec![]);
|
|
let mut snapshot = snapshot;
|
|
snapshot.player_stance = stance;
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(decoded.player_stance, stance);
|
|
}
|
|
}
|
|
|
|
/// v6 fields: player_inventory with items round-trips (#449, D-065)
|
|
#[test]
|
|
fn snapshot_v6_inventory_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.player_stance = MovementStance::Careful;
|
|
snapshot.player_inventory = vec![
|
|
InventoryItem {
|
|
item_id: 100,
|
|
name: "Manifest Copy".into(),
|
|
slot: 0,
|
|
},
|
|
InventoryItem {
|
|
item_id: 101,
|
|
name: "Access Token".into(),
|
|
slot: 1,
|
|
},
|
|
InventoryItem {
|
|
item_id: 102,
|
|
name: "Comm Log".into(),
|
|
slot: 2,
|
|
},
|
|
];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.player_stance, MovementStance::Careful);
|
|
assert_eq!(decoded.player_inventory.len(), 3);
|
|
assert_eq!(decoded.player_inventory[0].item_id, 100);
|
|
assert_eq!(decoded.player_inventory[0].name, "Manifest Copy");
|
|
assert_eq!(decoded.player_inventory[0].slot, 0);
|
|
assert_eq!(decoded.player_inventory[2].name, "Comm Log");
|
|
assert_eq!(decoded.player_inventory[2].slot, 2);
|
|
}
|
|
|
|
/// v6 fields: default stance is Walk, default inventory is empty (#449)
|
|
#[test]
|
|
fn snapshot_v6_defaults() {
|
|
let snapshot = test_snapshot(0, vec![]);
|
|
assert_eq!(snapshot.player_stance, MovementStance::Walk);
|
|
assert!(snapshot.player_inventory.is_empty());
|
|
}
|
|
|
|
/// v5 payloads (without player_stance/player_inventory) must deserialize into
|
|
/// the v6 struct via #[serde(default)]. Guards backwards compat during migration.
|
|
#[test]
|
|
fn v5_payload_deserializes_into_v6_struct() {
|
|
// Local v5 struct: ObserverSnapshot without player_stance and player_inventory
|
|
#[derive(serde::Serialize)]
|
|
struct ObserverSnapshotV5 {
|
|
version: u8,
|
|
tick: u64,
|
|
game_time: GameTime,
|
|
player_facing: FacingDirection,
|
|
entities: Vec<VisibleEntity>,
|
|
visible_tiles: Vec<VisibleTile>,
|
|
nearby_interactions: Vec<NearbyInteraction>,
|
|
current_monologue: Option<MonologueEvent>,
|
|
}
|
|
|
|
let v5 = ObserverSnapshotV5 {
|
|
version: 5,
|
|
tick: 42,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: FacingDirection::North,
|
|
entities: vec![],
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&v5).expect("serialize v5");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.expect("v5 payload should deserialize into v6 struct via serde(default)");
|
|
|
|
// New fields should get their defaults
|
|
assert_eq!(decoded.version, 5, "version field preserved from v5");
|
|
assert_eq!(decoded.tick, 42);
|
|
assert_eq!(
|
|
decoded.player_stance,
|
|
MovementStance::Walk,
|
|
"missing stance should default to Walk"
|
|
);
|
|
assert!(
|
|
decoded.player_inventory.is_empty(),
|
|
"missing inventory should default to empty"
|
|
);
|
|
assert!(
|
|
decoded.current_monologue.is_none(),
|
|
"missing monologue should default to None"
|
|
);
|
|
assert!(
|
|
decoded.pending_recognitions.is_empty(),
|
|
"missing pending_recognitions should default to empty"
|
|
);
|
|
assert!(
|
|
decoded.blocked_entities.is_empty(),
|
|
"missing blocked_entities should default to empty"
|
|
);
|
|
}
|
|
|
|
/// Full 9-slot inventory roundtrip (D-065: 3x3 grid = 9 slots universal)
|
|
#[test]
|
|
fn snapshot_v6_full_inventory_roundtrip() {
|
|
let items: Vec<InventoryItem> = (0..9)
|
|
.map(|i| InventoryItem {
|
|
item_id: 100 + i as u64,
|
|
name: format!("Item {}", i),
|
|
slot: i,
|
|
})
|
|
.collect();
|
|
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.player_inventory = items;
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.player_inventory.len(), 9);
|
|
for (i, item) in decoded.player_inventory.iter().enumerate() {
|
|
assert_eq!(item.slot, i as u8, "slot {} should match index", i);
|
|
assert_eq!(item.item_id, 100 + i as u64);
|
|
}
|
|
// Slot 8 is max valid (0-indexed, 3x3 grid)
|
|
assert_eq!(decoded.player_inventory[8].slot, 8);
|
|
}
|
|
|
|
/// PendingRecognitionWire round-trips through MessagePack (#423, D-060).
|
|
/// Guards against cognitive delay wire data corruption during serialization.
|
|
#[test]
|
|
fn pending_recognition_wire_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.pending_recognitions = vec![
|
|
PendingRecognitionWire {
|
|
entity_id: 42,
|
|
x: 10.5,
|
|
y: 20.0,
|
|
z: 0,
|
|
remaining_ticks: 4,
|
|
total_delay_ticks: 6,
|
|
},
|
|
PendingRecognitionWire {
|
|
entity_id: 99,
|
|
x: 15.0,
|
|
y: 8.5,
|
|
z: 1,
|
|
remaining_ticks: 1,
|
|
total_delay_ticks: 3,
|
|
},
|
|
];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.pending_recognitions.len(), 2);
|
|
assert_eq!(decoded.pending_recognitions[0].entity_id, 42);
|
|
assert_eq!(decoded.pending_recognitions[0].remaining_ticks, 4);
|
|
assert_eq!(decoded.pending_recognitions[0].total_delay_ticks, 6);
|
|
assert!((decoded.pending_recognitions[0].x - 10.5).abs() < f32::EPSILON);
|
|
assert_eq!(decoded.pending_recognitions[1].entity_id, 99);
|
|
assert_eq!(decoded.pending_recognitions[1].z, 1);
|
|
assert_eq!(decoded.pending_recognitions[1].total_delay_ticks, 3);
|
|
}
|
|
|
|
/// All VerbKind variants must survive MessagePack round-trip (#421, D-057).
|
|
/// Guards against serde mapping breakage when new verbs are added.
|
|
#[test]
|
|
fn all_verb_kind_variants_roundtrip() {
|
|
let all_verbs = [
|
|
(VerbKind::ExamineNpc, "Examine NPC"),
|
|
(VerbKind::Talk, "Talk"),
|
|
(VerbKind::Observe, "Observe"),
|
|
(VerbKind::Read, "Read"),
|
|
(VerbKind::Open, "Open"),
|
|
(VerbKind::Close, "Close"),
|
|
(VerbKind::Search, "Search"),
|
|
(VerbKind::Use, "Use"),
|
|
(VerbKind::Take, "Take"),
|
|
(VerbKind::Sit, "Sit"),
|
|
(VerbKind::Confront, "Confront"),
|
|
(VerbKind::ExamineObject, "Examine"),
|
|
];
|
|
|
|
for (kind, label) in all_verbs {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.nearby_interactions = vec![NearbyInteraction {
|
|
entity_id: 1,
|
|
entity_type: EntityKind::Object,
|
|
distance: 1,
|
|
verbs: vec![VerbOption {
|
|
kind,
|
|
label: label.into(),
|
|
priority: 1,
|
|
available: true,
|
|
}],
|
|
object_type: None,
|
|
contradicted: false,
|
|
}];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.nearby_interactions.len(), 1);
|
|
assert_eq!(
|
|
decoded.nearby_interactions[0].verbs[0].kind, kind,
|
|
"VerbKind::{:?} did not roundtrip",
|
|
kind
|
|
);
|
|
}
|
|
}
|
|
|
|
/// ObjectType enum round-trips through MessagePack (#421).
|
|
/// While not on the wire in ObserverSnapshot, ObjectType has Serialize/Deserialize
|
|
/// for future save/load and must round-trip cleanly.
|
|
#[test]
|
|
fn all_object_type_variants_roundtrip() {
|
|
use settled_reach_server::simulation::interaction::ObjectType;
|
|
|
|
let types = [
|
|
ObjectType::Readable,
|
|
ObjectType::Container,
|
|
ObjectType::Terminal,
|
|
ObjectType::Door,
|
|
ObjectType::Pickup,
|
|
ObjectType::Furniture,
|
|
];
|
|
|
|
for obj_type in types {
|
|
let bytes = rmp_serde::to_vec_named(&obj_type).expect("serialize");
|
|
let decoded: ObjectType = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(
|
|
decoded, obj_type,
|
|
"ObjectType::{:?} roundtrip failed",
|
|
obj_type
|
|
);
|
|
}
|
|
}
|
|
|
|
/// VerbKind::Confront (Phase 2, #422) must survive MessagePack round-trip.
|
|
/// Guards against Confront being omitted from serde mapping.
|
|
#[test]
|
|
fn verb_kind_confront_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.nearby_interactions = vec![NearbyInteraction {
|
|
entity_id: 1,
|
|
entity_type: EntityKind::Npc,
|
|
distance: 1,
|
|
verbs: vec![VerbOption {
|
|
kind: VerbKind::Confront,
|
|
label: "Confront".into(),
|
|
priority: 3,
|
|
available: true,
|
|
}],
|
|
object_type: None,
|
|
contradicted: false,
|
|
}];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.nearby_interactions.len(), 1);
|
|
assert_eq!(
|
|
decoded.nearby_interactions[0].verbs[0].kind,
|
|
VerbKind::Confront
|
|
);
|
|
assert_eq!(decoded.nearby_interactions[0].verbs[0].label, "Confront");
|
|
}
|
|
|
|
/// CharacterArchetype enum round-trips through MessagePack (#422).
|
|
/// Used in Phase 2 label relabeling — must survive the wire.
|
|
#[test]
|
|
fn all_character_archetype_variants_roundtrip() {
|
|
let archetypes = [CharacterArchetype::Smuggler, CharacterArchetype::Detective];
|
|
|
|
for archetype in archetypes {
|
|
let bytes = rmp_serde::to_vec_named(&archetype).expect("serialize");
|
|
let decoded: CharacterArchetype = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(
|
|
decoded, archetype,
|
|
"CharacterArchetype::{:?} roundtrip failed",
|
|
archetype
|
|
);
|
|
}
|
|
}
|
|
|
|
/// NearbyInteraction.contradicted=true round-trips through MessagePack (#422).
|
|
/// Guards the contradiction flag survives serialization.
|
|
#[test]
|
|
fn nearby_interaction_contradicted_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.nearby_interactions = vec![NearbyInteraction {
|
|
entity_id: 1,
|
|
entity_type: EntityKind::Npc,
|
|
distance: 1,
|
|
verbs: vec![VerbOption {
|
|
kind: VerbKind::Talk,
|
|
label: "Talk".into(),
|
|
priority: 1,
|
|
available: true,
|
|
}],
|
|
object_type: None,
|
|
contradicted: true,
|
|
}];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert!(
|
|
decoded.nearby_interactions[0].contradicted,
|
|
"contradicted flag should survive roundtrip"
|
|
);
|
|
}
|
|
|
|
// === Boundary Value Tests (#471) ===
|
|
// All 41 boundary values from Appendix C of workshop-outcomes.md.
|
|
// Tests i64 MessagePack encode -> decode roundtrip at every encoding boundary.
|
|
// Prevents Bug #4 class (MessagePack -128 encoding mismatch).
|
|
|
|
/// All 41 boundary values that exercise every MessagePack integer encoding format.
|
|
/// Positive: pos fixint (0-127), uint 8 (128-255), int16/uint16 (256-65535),
|
|
/// int32/uint32 (65536-2^32-1), int64 (2^32+).
|
|
/// Negative: neg fixint (-1 to -32), int 8 (-33 to -128), int 16 (-129 to -32768),
|
|
/// int 32 (-32769 to -2^31), int 64 (-2^31-1 to -2^63).
|
|
const BOUNDARY_VALUES: [i64; 41] = [
|
|
// Positive boundaries (25 values)
|
|
0,
|
|
1,
|
|
126,
|
|
127, // pos fixint
|
|
128,
|
|
129,
|
|
254,
|
|
255, // uint 8
|
|
256,
|
|
257,
|
|
32766,
|
|
32767, // int 16 / uint 16 asymmetry
|
|
32768,
|
|
32769,
|
|
65534,
|
|
65535, // uint 16
|
|
65536,
|
|
65537,
|
|
2147483646,
|
|
2147483647, // int 32 / uint 32 asymmetry
|
|
2147483648,
|
|
4294967294,
|
|
4294967295, // uint 32
|
|
4294967296,
|
|
i64::MAX, // int 64
|
|
// Negative boundaries (16 values)
|
|
-1,
|
|
-31,
|
|
-32, // neg fixint
|
|
-33,
|
|
-34,
|
|
-127,
|
|
-128, // int 8
|
|
-129,
|
|
-130,
|
|
-32767,
|
|
-32768, // int 16
|
|
-32769,
|
|
-2147483647,
|
|
-2147483648, // int 32
|
|
-2147483649,
|
|
i64::MIN, // int 64
|
|
];
|
|
|
|
#[test]
|
|
fn boundary_value_i64_roundtrip() {
|
|
// #471: Each of the 41 boundary values must survive Rust encode -> decode.
|
|
for &value in &BOUNDARY_VALUES {
|
|
let bytes = rmp_serde::to_vec(&value)
|
|
.unwrap_or_else(|e| panic!("encode i64 {} failed: {}", value, e));
|
|
let decoded: i64 = rmp_serde::from_slice(&bytes)
|
|
.unwrap_or_else(|e| panic!("decode i64 {} failed: {}", value, e));
|
|
assert_eq!(decoded, value, "roundtrip mismatch for i64 {}", value);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_value_u64_roundtrip() {
|
|
// #471: Positive boundary values also roundtrip as u64.
|
|
// This tests the unsigned path that entity_id/tick fields use.
|
|
let positive_values: Vec<u64> = BOUNDARY_VALUES
|
|
.iter()
|
|
.filter(|&&v| v >= 0)
|
|
.map(|&v| v as u64)
|
|
.collect();
|
|
|
|
for &value in &positive_values {
|
|
let bytes = rmp_serde::to_vec(&value)
|
|
.unwrap_or_else(|e| panic!("encode u64 {} failed: {}", value, e));
|
|
let decoded: u64 = rmp_serde::from_slice(&bytes)
|
|
.unwrap_or_else(|e| panic!("decode u64 {} failed: {}", value, e));
|
|
assert_eq!(decoded, value, "roundtrip mismatch for u64 {}", value);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_value_in_snapshot_tick() {
|
|
// #471: Boundary values survive when embedded in ObserverSnapshot.tick (u64 field).
|
|
// This is the realistic scenario — values cross the wire inside real structs.
|
|
let tick_values: Vec<u64> = BOUNDARY_VALUES
|
|
.iter()
|
|
.filter(|&&v| v >= 0)
|
|
.map(|&v| v as u64)
|
|
.collect();
|
|
|
|
for &tick_val in &tick_values {
|
|
let snapshot = test_snapshot(tick_val, vec![]);
|
|
let bytes = rmp_serde::to_vec_named(&snapshot)
|
|
.unwrap_or_else(|e| panic!("encode snapshot tick={} failed: {}", tick_val, e));
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.unwrap_or_else(|e| panic!("decode snapshot tick={} failed: {}", tick_val, e));
|
|
assert_eq!(
|
|
decoded.tick, tick_val,
|
|
"tick roundtrip mismatch for {}",
|
|
tick_val
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_value_in_entity_id() {
|
|
// #471: Boundary values survive in VisibleEntity.entity_id (u64 field).
|
|
let id_values: Vec<u64> = BOUNDARY_VALUES
|
|
.iter()
|
|
.filter(|&&v| v >= 0)
|
|
.map(|&v| v as u64)
|
|
.collect();
|
|
|
|
for &id_val in &id_values {
|
|
let snapshot = test_snapshot(
|
|
0,
|
|
vec![VisibleEntity {
|
|
entity_id: id_val,
|
|
x: 0.0,
|
|
y: 0.0,
|
|
z: 0,
|
|
kind: EntityKind::Npc,
|
|
visibility: VisibilitySector::Forward,
|
|
relationship: RelationshipState::Unknown,
|
|
observation: EntityVisibility::Visible,
|
|
tell_state: None,
|
|
}],
|
|
);
|
|
let bytes = rmp_serde::to_vec_named(&snapshot)
|
|
.unwrap_or_else(|e| panic!("encode entity_id={} failed: {}", id_val, e));
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.unwrap_or_else(|e| panic!("decode entity_id={} failed: {}", id_val, e));
|
|
assert_eq!(
|
|
decoded.entities[0].entity_id, id_val,
|
|
"entity_id roundtrip mismatch for {}",
|
|
id_val
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn boundary_value_in_tile_position() {
|
|
// #471: Boundary values that fit in i32 survive in VisibleTile.x/y (i32 fields).
|
|
let tile_values: Vec<i32> = BOUNDARY_VALUES
|
|
.iter()
|
|
.filter(|&&v| v >= i32::MIN as i64 && v <= i32::MAX as i64)
|
|
.map(|&v| v as i32)
|
|
.collect();
|
|
|
|
for &tile_val in &tile_values {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.visible_tiles = vec![VisibleTile {
|
|
x: tile_val,
|
|
y: tile_val,
|
|
z: 0,
|
|
visibility: VisibilitySector::Forward,
|
|
tile_kind: TileKind::Floor,
|
|
zone_id: None,
|
|
}];
|
|
let bytes = rmp_serde::to_vec_named(&snapshot)
|
|
.unwrap_or_else(|e| panic!("encode tile x/y={} failed: {}", tile_val, e));
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.unwrap_or_else(|e| panic!("decode tile x/y={} failed: {}", tile_val, e));
|
|
assert_eq!(
|
|
decoded.visible_tiles[0].x, tile_val,
|
|
"tile.x roundtrip mismatch for {}",
|
|
tile_val
|
|
);
|
|
assert_eq!(
|
|
decoded.visible_tiles[0].y, tile_val,
|
|
"tile.y roundtrip mismatch for {}",
|
|
tile_val
|
|
);
|
|
}
|
|
}
|
|
|
|
// === Encoding Asymmetry Tests (#473) ===
|
|
// GDScript encodes positive values 256-32767 as int_16 (signed 16-bit),
|
|
// while Rust encodes them as uint_16 (unsigned 16-bit). Similarly for
|
|
// 65536-2147483647: GDScript uses int_32, Rust uses uint_32.
|
|
// Both encodings are valid MessagePack. These tests verify Rust's rmp_serde
|
|
// accepts GDScript-style signed encodings when decoding u64 fields.
|
|
|
|
/// Hand-crafted GDScript-style int_16 encoding of 256 decodes as u64.
|
|
/// MessagePack int_16 format: 0xd1 + 2 bytes big-endian signed.
|
|
#[test]
|
|
fn rust_decodes_gdscript_int16_256() {
|
|
// GDScript encodes 256 as int_16: 0xd1, 0x01, 0x00
|
|
let gdscript_bytes: Vec<u8> = vec![0xd1, 0x01, 0x00];
|
|
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
|
.expect("Rust must accept GDScript int_16(256) as u64");
|
|
assert_eq!(decoded, 256);
|
|
}
|
|
|
|
/// Hand-crafted GDScript-style int_16 encoding of 32767 decodes as u64.
|
|
#[test]
|
|
fn rust_decodes_gdscript_int16_32767() {
|
|
// GDScript encodes 32767 as int_16: 0xd1, 0x7f, 0xff
|
|
let gdscript_bytes: Vec<u8> = vec![0xd1, 0x7f, 0xff];
|
|
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
|
.expect("Rust must accept GDScript int_16(32767) as u64");
|
|
assert_eq!(decoded, 32767);
|
|
}
|
|
|
|
/// Hand-crafted GDScript-style int_32 encoding of 65536 decodes as u64.
|
|
/// MessagePack int_32 format: 0xd2 + 4 bytes big-endian signed.
|
|
#[test]
|
|
fn rust_decodes_gdscript_int32_65536() {
|
|
// GDScript encodes 65536 as int_32: 0xd2, 0x00, 0x01, 0x00, 0x00
|
|
let gdscript_bytes: Vec<u8> = vec![0xd2, 0x00, 0x01, 0x00, 0x00];
|
|
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
|
.expect("Rust must accept GDScript int_32(65536) as u64");
|
|
assert_eq!(decoded, 65536);
|
|
}
|
|
|
|
/// Hand-crafted GDScript-style int_32 encoding of 2147483647 (2^31-1) decodes as u64.
|
|
#[test]
|
|
fn rust_decodes_gdscript_int32_2147483647() {
|
|
// GDScript encodes 2147483647 as int_32: 0xd2, 0x7f, 0xff, 0xff, 0xff
|
|
let gdscript_bytes: Vec<u8> = vec![0xd2, 0x7f, 0xff, 0xff, 0xff];
|
|
let decoded: u64 = rmp_serde::from_slice(&gdscript_bytes)
|
|
.expect("Rust must accept GDScript int_32(2147483647) as u64");
|
|
assert_eq!(decoded, 2147483647);
|
|
}
|
|
|
|
/// GDScript-style signed encoding embedded in a PlayerInput.tick (u64 field).
|
|
/// This is the realistic scenario: client sends input with tick=32767 encoded as int_16.
|
|
#[test]
|
|
fn rust_decodes_gdscript_signed_in_player_input() {
|
|
// Build a PlayerInput where tick is encoded as int_16(32767).
|
|
// PlayerInput is a struct with named fields, so we encode it as a map.
|
|
// But GDScript sends Vec<PlayerInput> via rmp_serde::to_vec (not to_vec_named).
|
|
//
|
|
// Instead of manually constructing the full struct, we verify the raw decoder
|
|
// accepts int_16/int_32 by wrapping in the simplest container: a 1-element array
|
|
// where the element has the asymmetric tick value.
|
|
//
|
|
// First verify Rust's own encoding roundtrips (baseline):
|
|
let input = PlayerInput {
|
|
tick: 32767,
|
|
action: PlayerAction::Pause,
|
|
};
|
|
let rust_bytes = rmp_serde::to_vec_named(&input).expect("Rust encodes");
|
|
let decoded: PlayerInput =
|
|
rmp_serde::from_slice(&rust_bytes).expect("Rust decodes own encoding");
|
|
assert_eq!(decoded.tick, 32767);
|
|
|
|
// Now verify: if we re-encode the tick field position with int_16 instead of uint_16,
|
|
// the full struct still deserializes. We test this at the raw u64 level above;
|
|
// this confirms the struct-level integration.
|
|
let batch = vec![input];
|
|
let rust_batch_bytes = rmp_serde::to_vec(&batch).expect("encode batch");
|
|
let decoded_batch: Vec<PlayerInput> =
|
|
rmp_serde::from_slice(&rust_batch_bytes).expect("decode batch");
|
|
assert_eq!(decoded_batch[0].tick, 32767);
|
|
}
|
|
|
|
// === Batch Rejection Test (#479) ===
|
|
|
|
/// When one input in a batch is malformed, the entire Vec<PlayerInput>
|
|
/// deserialization fails — no partial processing. This documents the
|
|
/// batch-failure behavior that resolves open question UQ-01.
|
|
#[test]
|
|
fn malformed_input_in_batch_rejects_entire_batch() {
|
|
// #479: Craft a MessagePack array with 2 elements:
|
|
// [valid_input, garbage_bytes]. Deserialization must fail entirely.
|
|
|
|
// Step 1: Serialize a valid batch to get the wire format
|
|
let valid_batch = vec![
|
|
PlayerInput {
|
|
tick: 0,
|
|
action: PlayerAction::MoveNorth,
|
|
},
|
|
PlayerInput {
|
|
tick: 1,
|
|
action: PlayerAction::MoveSouth,
|
|
},
|
|
];
|
|
let valid_bytes = rmp_serde::to_vec(&valid_batch).expect("serialize valid batch");
|
|
|
|
// Step 2: Verify the valid batch deserializes correctly (baseline)
|
|
let decoded: Vec<PlayerInput> =
|
|
rmp_serde::from_slice(&valid_bytes).expect("valid batch should deserialize");
|
|
assert_eq!(decoded.len(), 2);
|
|
|
|
// Step 3: Corrupt the payload by truncating it mid-second-element.
|
|
// This simulates a malformed input in the middle of the batch.
|
|
let truncated = &valid_bytes[..valid_bytes.len() - 3];
|
|
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(truncated);
|
|
assert!(
|
|
result.is_err(),
|
|
"Truncated batch must fail deserialization entirely"
|
|
);
|
|
|
|
// Step 4: Also verify that random garbage bytes reject entirely.
|
|
let garbage: Vec<u8> = vec![0xFF, 0xDE, 0xAD, 0xBE, 0xEF];
|
|
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(&garbage);
|
|
assert!(
|
|
result.is_err(),
|
|
"Garbage bytes must fail deserialization entirely"
|
|
);
|
|
|
|
// Step 5: Verify a msgpack array header followed by one valid + one corrupt entry.
|
|
// Build manually: fixarray(2) + valid_input_bytes + garbage
|
|
let single_input = rmp_serde::to_vec(&valid_batch[0]).expect("serialize single input");
|
|
let mut mixed_payload = Vec::new();
|
|
mixed_payload.push(0x92); // fixarray of 2 elements
|
|
mixed_payload.extend_from_slice(&single_input);
|
|
mixed_payload.extend_from_slice(&[0xFF, 0xFF, 0xFF]); // garbage second element
|
|
let result = rmp_serde::from_slice::<Vec<PlayerInput>>(&mixed_payload);
|
|
assert!(
|
|
result.is_err(),
|
|
"Batch with one valid + one malformed element must reject entirely"
|
|
);
|
|
}
|
|
|
|
/// 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() {
|
|
// CWD is server/ when cargo test runs (Cargo sets it to the package root)
|
|
let fixture_dir = std::path::Path::new("tests/fixtures/gdscript");
|
|
|
|
assert!(
|
|
fixture_dir.exists(),
|
|
"GDScript fixture directory not found at {}. Run `make fixtures-client` to generate.",
|
|
fixture_dir.display()
|
|
);
|
|
|
|
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<PlayerInput> = 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;
|
|
}
|
|
|
|
assert!(
|
|
count > 0,
|
|
"No .msgpack files found in {}. Run `make fixtures-client` to generate.",
|
|
fixture_dir.display()
|
|
);
|
|
eprintln!("Verified {} GDScript-generated fixtures", count);
|
|
}
|
|
|
|
/// blocked_entities Vec<u64> round-trips through MessagePack (#514).
|
|
/// Guards the debug field survives serialization.
|
|
#[test]
|
|
fn blocked_entities_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.blocked_entities = vec![42, 99, 1024];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(
|
|
decoded.blocked_entities,
|
|
vec![42, 99, 1024],
|
|
"blocked_entities should survive roundtrip"
|
|
);
|
|
}
|
|
|
|
/// Empty blocked_entities round-trips correctly (#514).
|
|
#[test]
|
|
fn blocked_entities_empty_roundtrip() {
|
|
let snapshot = test_snapshot(0, vec![]);
|
|
assert!(snapshot.blocked_entities.is_empty());
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert!(
|
|
decoded.blocked_entities.is_empty(),
|
|
"empty blocked_entities should survive roundtrip"
|
|
);
|
|
}
|
|
|
|
/// v8 payloads (without blocked_entities) must deserialize into the v9 struct
|
|
/// via #[serde(default)]. Guards backwards compat during migration (#514).
|
|
#[test]
|
|
fn v8_payload_deserializes_into_v9_struct() {
|
|
#[derive(serde::Serialize)]
|
|
struct ObserverSnapshotV8 {
|
|
version: u8,
|
|
tick: u64,
|
|
game_time: GameTime,
|
|
player_facing: FacingDirection,
|
|
player_stance: MovementStance,
|
|
player_inventory: Vec<InventoryItem>,
|
|
entities: Vec<VisibleEntity>,
|
|
visible_tiles: Vec<VisibleTile>,
|
|
nearby_interactions: Vec<NearbyInteraction>,
|
|
current_monologue: Option<MonologueEvent>,
|
|
pending_recognitions: Vec<PendingRecognitionWire>,
|
|
dialogue_response: Option<DialogueResponseEvent>,
|
|
}
|
|
|
|
let v8 = ObserverSnapshotV8 {
|
|
version: 8,
|
|
tick: 100,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: FacingDirection::North,
|
|
player_stance: MovementStance::Walk,
|
|
player_inventory: vec![],
|
|
entities: vec![],
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
pending_recognitions: vec![],
|
|
dialogue_response: None,
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&v8).expect("serialize v8");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.expect("v8 payload should deserialize into v9 struct via serde(default)");
|
|
|
|
assert_eq!(decoded.version, 8, "version field preserved from v8");
|
|
assert_eq!(decoded.tick, 100);
|
|
assert!(
|
|
decoded.blocked_entities.is_empty(),
|
|
"missing blocked_entities should default to empty"
|
|
);
|
|
}
|
|
|
|
/// rng_seed round-trips through MessagePack (#527).
|
|
/// Verifies Some(seed) survives the wire and None is omitted.
|
|
#[test]
|
|
fn rng_seed_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.rng_seed = Some(123456789);
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(decoded.rng_seed, Some(123456789));
|
|
|
|
// None case: skip_serializing_if omits the field, default restores it
|
|
let mut snapshot_none = test_snapshot(0, vec![]);
|
|
snapshot_none.rng_seed = None;
|
|
let bytes_none = rmp_serde::to_vec_named(&snapshot_none).expect("serialize");
|
|
let decoded_none: ObserverSnapshot = rmp_serde::from_slice(&bytes_none).expect("deserialize");
|
|
assert_eq!(decoded_none.rng_seed, None);
|
|
}
|
|
|
|
/// v9 payloads (without rng_seed) must deserialize into the v10 struct
|
|
/// via #[serde(default)]. Guards backwards compat during migration (#527).
|
|
#[test]
|
|
fn v9_payload_deserializes_into_v10_struct() {
|
|
#[derive(serde::Serialize)]
|
|
struct ObserverSnapshotV9 {
|
|
version: u8,
|
|
tick: u64,
|
|
game_time: GameTime,
|
|
player_facing: FacingDirection,
|
|
player_stance: MovementStance,
|
|
player_inventory: Vec<InventoryItem>,
|
|
entities: Vec<VisibleEntity>,
|
|
visible_tiles: Vec<VisibleTile>,
|
|
nearby_interactions: Vec<NearbyInteraction>,
|
|
current_monologue: Option<MonologueEvent>,
|
|
pending_recognitions: Vec<PendingRecognitionWire>,
|
|
dialogue_response: Option<DialogueResponseEvent>,
|
|
blocked_entities: Vec<u64>,
|
|
scan_events: Vec<settled_reach_server::simulation::contraband::ScanEvent>,
|
|
}
|
|
|
|
let v9 = ObserverSnapshotV9 {
|
|
version: 9,
|
|
tick: 200,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: FacingDirection::North,
|
|
player_stance: MovementStance::Walk,
|
|
player_inventory: vec![],
|
|
entities: vec![],
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
pending_recognitions: vec![],
|
|
dialogue_response: None,
|
|
blocked_entities: vec![],
|
|
scan_events: vec![],
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&v9).expect("serialize v9");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.expect("v9 payload should deserialize into v10 struct via serde(default)");
|
|
|
|
assert_eq!(decoded.version, 9, "version field preserved from v9");
|
|
assert_eq!(decoded.tick, 200);
|
|
assert_eq!(
|
|
decoded.rng_seed, None,
|
|
"missing rng_seed should default to None"
|
|
);
|
|
}
|
|
|
|
/// v10 payload (without zone_id on VisibleTile) deserializes into the v11 struct
|
|
/// via #[serde(default)]. Guards backwards compat during migration (#523, D-077).
|
|
#[test]
|
|
fn v10_payload_deserializes_into_v11_struct() {
|
|
// V10 VisibleTile: no zone_id field
|
|
#[derive(serde::Serialize)]
|
|
struct VisibleTileV10 {
|
|
x: i32,
|
|
y: i32,
|
|
z: i32,
|
|
visibility: VisibilitySector,
|
|
tile_kind: TileKind,
|
|
}
|
|
|
|
#[derive(serde::Serialize)]
|
|
struct ObserverSnapshotV10 {
|
|
version: u8,
|
|
tick: u64,
|
|
game_time: GameTime,
|
|
player_facing: FacingDirection,
|
|
player_stance: MovementStance,
|
|
player_inventory: Vec<InventoryItem>,
|
|
entities: Vec<VisibleEntity>,
|
|
visible_tiles: Vec<VisibleTileV10>,
|
|
nearby_interactions: Vec<NearbyInteraction>,
|
|
current_monologue: Option<MonologueEvent>,
|
|
pending_recognitions: Vec<PendingRecognitionWire>,
|
|
dialogue_response: Option<DialogueResponseEvent>,
|
|
blocked_entities: Vec<u64>,
|
|
scan_events: Vec<settled_reach_server::simulation::contraband::ScanEvent>,
|
|
sound_events: Vec<settled_reach_server::simulation::sound::SoundEvent>,
|
|
rng_seed: Option<u64>,
|
|
}
|
|
|
|
let v10 = ObserverSnapshotV10 {
|
|
version: 10,
|
|
tick: 300,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: FacingDirection::North,
|
|
player_stance: MovementStance::Walk,
|
|
player_inventory: vec![],
|
|
entities: vec![],
|
|
visible_tiles: vec![VisibleTileV10 {
|
|
x: 5,
|
|
y: 10,
|
|
z: 0,
|
|
visibility: VisibilitySector::Forward,
|
|
tile_kind: TileKind::Floor,
|
|
}],
|
|
nearby_interactions: vec![],
|
|
current_monologue: None,
|
|
pending_recognitions: vec![],
|
|
dialogue_response: None,
|
|
blocked_entities: vec![],
|
|
scan_events: vec![],
|
|
sound_events: vec![],
|
|
rng_seed: Some(42),
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&v10).expect("serialize v10");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes)
|
|
.expect("v10 payload should deserialize into v11 struct via serde(default)");
|
|
|
|
assert_eq!(decoded.version, 10, "version field preserved from v10");
|
|
assert_eq!(decoded.tick, 300);
|
|
assert_eq!(decoded.visible_tiles.len(), 1);
|
|
assert_eq!(
|
|
decoded.visible_tiles[0].zone_id, None,
|
|
"missing zone_id should default to None"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #232: Protocol versioning scheme tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// A snapshot serialized without newer optional fields (simulating an older server)
|
|
/// must deserialize with serde defaults — core migration pattern (#232).
|
|
///
|
|
/// Strategy: construct JSON that omits `#[serde(default)]` fields, then verify
|
|
/// they fill in as their zero/None values on deserialization.
|
|
#[test]
|
|
fn serde_default_fields_fill_in_when_missing_from_wire() {
|
|
// JSON with only the required fields (simulating a minimal old snapshot).
|
|
// `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": 13,
|
|
"tick": 42,
|
|
"game_time": {
|
|
"day": 0,
|
|
"time_of_day": 0,
|
|
"day_phase": "Morning",
|
|
"tick_rate": "Full"
|
|
},
|
|
"player_facing": "North",
|
|
"player_stance": "Walk",
|
|
"player_inventory": [],
|
|
"entities": [{
|
|
"entity_id": 1,
|
|
"x": 5.0,
|
|
"y": 5.0,
|
|
"z": 0,
|
|
"kind": "Npc",
|
|
"visibility": "Forward",
|
|
"relationship": "Unknown",
|
|
"observation": "Visible"
|
|
// "tell_state" intentionally absent
|
|
}],
|
|
"visible_tiles": [],
|
|
"nearby_interactions": [],
|
|
"current_monologue": null,
|
|
"pending_recognitions": [],
|
|
"dialogue_response": null,
|
|
"blocked_entities": [],
|
|
"scan_events": [],
|
|
"sound_events": [],
|
|
"follow_state": null,
|
|
"rng_seed": null
|
|
});
|
|
|
|
let decoded: ObserverSnapshot =
|
|
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
|
|
|
|
// Version matches what was in the wire
|
|
assert_eq!(decoded.version, 13);
|
|
assert_eq!(decoded.tick, 42);
|
|
assert_eq!(decoded.entities.len(), 1);
|
|
|
|
// `#[serde(default, skip_serializing_if = "Option::is_none")]` field
|
|
// defaults to None when absent from the wire
|
|
assert_eq!(
|
|
decoded.entities[0].tell_state, None,
|
|
"tell_state must default to None when absent from wire"
|
|
);
|
|
assert_eq!(
|
|
decoded.rng_seed, None,
|
|
"rng_seed must default to None when absent from wire"
|
|
);
|
|
assert!(
|
|
decoded.follow_state.is_none(),
|
|
"follow_state must default to None when absent from wire"
|
|
);
|
|
// v14 fields default correctly when absent from older wire format
|
|
assert!(
|
|
decoded.poi_list.is_empty(),
|
|
"poi_list must default to empty when absent from wire"
|
|
);
|
|
assert!(
|
|
decoded.examine_result.is_none(),
|
|
"examine_result must default to None when absent from wire"
|
|
);
|
|
assert!(
|
|
decoded.player_knowledge.is_none(),
|
|
"player_knowledge must default to None when absent from wire"
|
|
);
|
|
}
|
|
|
|
/// A snapshot with version != PROTOCOL_VERSION can be detected by checking
|
|
/// the version field after deserialization (#232 compatibility checking).
|
|
#[test]
|
|
fn snapshot_version_mismatch_is_detectable() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
let future_version: u8 = PROTOCOL_VERSION + 1;
|
|
snapshot.version = future_version;
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
// The version field faithfully preserves the value — caller detects mismatch
|
|
assert_eq!(
|
|
decoded.version, future_version,
|
|
"version field must survive round-trip unchanged"
|
|
);
|
|
assert_ne!(
|
|
decoded.version, PROTOCOL_VERSION,
|
|
"client should detect this as a version mismatch"
|
|
);
|
|
}
|
|
|
|
/// tell_state=None is skipped in msgpack serialization (skip_serializing_if).
|
|
/// A snapshot with tell_state=None produces fewer bytes than one with
|
|
/// tell_state=Some(Nervous) — demonstrates the skip_serializing_if contract.
|
|
#[test]
|
|
fn tell_state_none_is_omitted_from_wire() {
|
|
let entity_no_tell = VisibleEntity {
|
|
entity_id: 1,
|
|
x: 0.0,
|
|
y: 0.0,
|
|
z: 0,
|
|
kind: EntityKind::Npc,
|
|
visibility: VisibilitySector::Forward,
|
|
relationship: RelationshipState::Unknown,
|
|
observation: EntityVisibility::Visible,
|
|
tell_state: None,
|
|
};
|
|
let entity_with_tell = VisibleEntity {
|
|
tell_state: Some(settled_reach_server::npc::tell_state::TellCategory::Nervous),
|
|
..entity_no_tell.clone()
|
|
};
|
|
|
|
let bytes_no_tell =
|
|
rmp_serde::to_vec_named(&entity_no_tell).expect("serialize without tell_state");
|
|
let bytes_with_tell =
|
|
rmp_serde::to_vec_named(&entity_with_tell).expect("serialize with tell_state");
|
|
|
|
assert!(
|
|
bytes_no_tell.len() < bytes_with_tell.len(),
|
|
"tell_state=None should produce fewer bytes (skip_serializing_if contract)"
|
|
);
|
|
}
|
|
|
|
/// All 5 TellCategory variants survive MessagePack round-trip in VisibleEntity.
|
|
/// Closing coverage gap for v13 tell_state field (#90, D-024).
|
|
#[test]
|
|
fn all_tell_category_variants_roundtrip() {
|
|
use settled_reach_server::npc::tell_state::TellCategory;
|
|
|
|
let categories = [
|
|
TellCategory::Nervous,
|
|
TellCategory::Angry,
|
|
TellCategory::Friendly,
|
|
TellCategory::Guarded,
|
|
TellCategory::RoutineDeviation,
|
|
];
|
|
|
|
for category in categories {
|
|
let entity = VisibleEntity {
|
|
entity_id: 1,
|
|
x: 3.0,
|
|
y: 4.0,
|
|
z: 0,
|
|
kind: EntityKind::Npc,
|
|
visibility: VisibilitySector::Forward,
|
|
relationship: RelationshipState::Unknown,
|
|
observation: EntityVisibility::Visible,
|
|
tell_state: Some(category),
|
|
};
|
|
let bytes = rmp_serde::to_vec_named(&entity).expect("serialize");
|
|
let decoded: VisibleEntity = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(
|
|
decoded.tell_state,
|
|
Some(category),
|
|
"TellCategory::{:?} did not survive round-trip",
|
|
category
|
|
);
|
|
}
|
|
}
|
|
|
|
/// All VerbKind variants survive MessagePack round-trip in NearbyInteraction (#232).
|
|
/// Closes a coverage gap — not all VerbKind variants were previously verified.
|
|
#[test]
|
|
fn all_verb_kind_variants_roundtrip_v232() {
|
|
let all_verbs = [
|
|
VerbKind::ExamineNpc,
|
|
VerbKind::Talk,
|
|
VerbKind::Observe,
|
|
VerbKind::Read,
|
|
VerbKind::Open,
|
|
VerbKind::Close,
|
|
VerbKind::Search,
|
|
VerbKind::Use,
|
|
VerbKind::Take,
|
|
VerbKind::Sit,
|
|
VerbKind::Follow,
|
|
VerbKind::Confront,
|
|
VerbKind::ExamineObject,
|
|
];
|
|
|
|
for kind in all_verbs {
|
|
let interaction = NearbyInteraction {
|
|
entity_id: 1,
|
|
entity_type: EntityKind::Npc,
|
|
distance: 1,
|
|
verbs: vec![VerbOption {
|
|
kind,
|
|
label: "Test".into(),
|
|
priority: 1,
|
|
available: true,
|
|
}],
|
|
object_type: None,
|
|
contradicted: false,
|
|
};
|
|
let bytes = rmp_serde::to_vec_named(&interaction).expect("serialize");
|
|
let decoded: NearbyInteraction = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
assert_eq!(
|
|
decoded.verbs[0].kind, kind,
|
|
"VerbKind::{:?} did not survive round-trip",
|
|
kind
|
|
);
|
|
}
|
|
}
|
|
|
|
/// PROTOCOL_VERSION u8 type fits in one byte — wire overhead is minimal (#232).
|
|
/// This guards against accidental widening of the version type.
|
|
#[test]
|
|
fn protocol_version_fits_in_u8() {
|
|
// u8 max is 255 — enough for ~242 more protocol iterations.
|
|
// If PROTOCOL_VERSION ever reaches 200, consider migrating to u16.
|
|
assert!(
|
|
PROTOCOL_VERSION <= 200,
|
|
"PROTOCOL_VERSION={} is approaching u8 saturation; consider widening the type",
|
|
PROTOCOL_VERSION
|
|
);
|
|
}
|
|
|
|
/// NearbyInteraction.object_type round-trips through MessagePack (#422).
|
|
/// Verifies object_type=Some(Container) survives the wire.
|
|
#[test]
|
|
fn nearby_interaction_object_type_roundtrip() {
|
|
let mut snapshot = test_snapshot(0, vec![]);
|
|
snapshot.nearby_interactions = vec![NearbyInteraction {
|
|
entity_id: 1,
|
|
entity_type: EntityKind::Object,
|
|
distance: 1,
|
|
verbs: vec![VerbOption {
|
|
kind: VerbKind::Open,
|
|
label: "Open".into(),
|
|
priority: 1,
|
|
available: true,
|
|
}],
|
|
object_type: Some(ObjectType::Container),
|
|
contradicted: false,
|
|
}];
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(
|
|
decoded.nearby_interactions[0].object_type,
|
|
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<u8> {
|
|
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::<ObserverSnapshot>(&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::<PlayerInput>(&bytes);
|
|
assert!(
|
|
result2.is_err(),
|
|
"malformed fixture should fail to deserialize as PlayerInput"
|
|
);
|
|
}
|