Server was sending visible_tiles without tile type data, so the client could not distinguish floor from wall in live mode. Add TileKind enum (Floor/Wall/Door/Object) to VisibleTile, populated from WalkabilityMap in the NaturalVision perception query. Update test fixtures to include the new field. Uses #[serde(default)] for backward compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
299 lines
10 KiB
Rust
299 lines
10 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,
|
|
entities,
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}],
|
|
);
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.version, 4);
|
|
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,
|
|
PlayerAction::UsePerceptionMode("thermal".to_string()),
|
|
PlayerAction::Pause,
|
|
PlayerAction::Unpause,
|
|
];
|
|
|
|
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") {
|
|
let snap = rmp_serde::from_slice::<ObserverSnapshot>(&bytes)
|
|
.unwrap_or_else(|e| panic!("deserialize snapshot fixture {}: {}", name, e));
|
|
assert_eq!(snap.version, 4, "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") {
|
|
rmp_serde::from_slice::<PlayerInput>(&bytes)
|
|
.unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e));
|
|
} else {
|
|
panic!("unknown fixture naming convention: {}", name);
|
|
}
|
|
count += 1;
|
|
}
|
|
assert!(count > 0, "no fixtures found");
|
|
eprintln!("Verified {} fixtures", count);
|
|
}
|
|
|
|
/// All EntityKind variants must survive MessagePack round-trip (D-030 Layer 1)
|
|
#[test]
|
|
fn all_entity_kind_variants_roundtrip() {
|
|
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,
|
|
};
|
|
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: 4,
|
|
tick: 100,
|
|
game_time: GameTime {
|
|
day: 3,
|
|
time_of_day: 720,
|
|
day_phase: DayPhase::Evening,
|
|
tick_rate: TickRate::Paused,
|
|
},
|
|
player_facing: FacingDirection::Southeast,
|
|
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,
|
|
}],
|
|
visible_tiles: vec![
|
|
VisibleTile {
|
|
x: 5,
|
|
y: 10,
|
|
z: 0,
|
|
visibility: VisibilitySector::Forward,
|
|
tile_kind: TileKind::Floor,
|
|
},
|
|
VisibleTile {
|
|
x: 6,
|
|
y: 10,
|
|
z: 0,
|
|
visibility: VisibilitySector::Peripheral,
|
|
tile_kind: TileKind::Wall,
|
|
},
|
|
],
|
|
nearby_interactions: vec![],
|
|
};
|
|
|
|
let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize");
|
|
let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");
|
|
|
|
assert_eq!(decoded.version, 4);
|
|
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, 4, "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: 4,
|
|
tick: 0,
|
|
game_time: GameTime {
|
|
day: 0,
|
|
time_of_day: 0,
|
|
day_phase: DayPhase::Morning,
|
|
tick_rate: TickRate::Full,
|
|
},
|
|
player_facing: dir,
|
|
entities: vec![],
|
|
visible_tiles: vec![],
|
|
nearby_interactions: vec![],
|
|
};
|
|
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);
|
|
}
|
|
}
|