feat(simulation): Sprint 6 Touch — stance, tile presence, verbs, protocol v6
Implements the core Sprint 6: Touch systems across 5 tickets: - #449 ObserverSnapshot v6: add player_stance (MovementStance) and player_inventory (Vec<InventoryItem>) wire fields with serde defaults for backward compatibility. Bump PROTOCOL_VERSION 5→6. - #417 Stance system: Sprint/Walk/Careful/Crouch movement stance with tick-based speed (1/2/3/4 ticks per move), monologue rate multipliers, and PlayerMoveCooldown component. ToggleStanceUp/Down player actions. - #420 TilePresence: posture-layer collision system allowing same-tile occupancy for different layers (Standing/Prone/Seated/Fixture). Layer-based collision in validate_movement. - #421 ObjectType component: Readable/Container/Terminal/Door/Pickup/ Furniture types with Phase 1 verb sets computed from type + proximity. - #422 Phase 2 verb filter: KG-gated observer-side verb processing — POI priority flips, Confront injection at KnowsDetails+, contradiction marking, archetype-specific label relabeling (Smuggler/Detective). 217 unit tests + 17 integration tests passing. All MessagePack fixtures regenerated for v6 wire format. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,8 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 10.5,
|
||||
|
||||
@@ -28,6 +28,8 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 100,
|
||||
x: 10.5,
|
||||
|
||||
@@ -66,7 +66,7 @@ fn player_moves_north_through_full_pipeline() {
|
||||
rmp_serde::from_slice(&response).expect("deserialize snapshot");
|
||||
|
||||
// Snapshot captures state at end of tick 0 (before advance_tick increments to 1)
|
||||
assert_eq!(snapshot.version, 5);
|
||||
assert_eq!(snapshot.version, 6);
|
||||
assert_eq!(snapshot.tick, 0);
|
||||
assert_eq!(snapshot.entities.len(), 1);
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities,
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
@@ -161,6 +163,8 @@ fn generate_msgpack_fixtures() {
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::Southeast,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 10.5,
|
||||
|
||||
@@ -16,6 +16,8 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> ObserverSnapshot {
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: FacingDirection::North,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities,
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
@@ -190,6 +192,8 @@ fn snapshot_v2_fields_roundtrip() {
|
||||
tick_rate: TickRate::Paused,
|
||||
},
|
||||
player_facing: FacingDirection::Southeast,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities: vec![VisibleEntity {
|
||||
entity_id: 1,
|
||||
x: 5.5,
|
||||
@@ -261,7 +265,7 @@ fn entity_to_bits_roundtrip() {
|
||||
fn protocol_version_constant_matches_snapshot() {
|
||||
let snapshot = test_snapshot(0, vec![]);
|
||||
assert_eq!(snapshot.version, PROTOCOL_VERSION);
|
||||
assert_eq!(PROTOCOL_VERSION, 5, "bump this assertion when protocol version changes");
|
||||
assert_eq!(PROTOCOL_VERSION, 6, "bump this assertion when protocol version changes");
|
||||
}
|
||||
|
||||
/// All FacingDirection variants round-trip
|
||||
@@ -289,6 +293,8 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
tick_rate: TickRate::Full,
|
||||
},
|
||||
player_facing: dir,
|
||||
player_stance: MovementStance::default(),
|
||||
player_inventory: vec![],
|
||||
entities: vec![],
|
||||
visible_tiles: vec![],
|
||||
nearby_interactions: vec![],
|
||||
@@ -299,3 +305,204 @@ fn all_facing_direction_variants_roundtrip() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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, "Observe"),
|
||||
(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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user