feat(simulation): Sprint 19 — 7 server systems

Protocol handshake (#555): HandshakeMessage as first IPC frame,
HandshakeState resource, forward-compatible input handling.

State serialization (#96): serialize_npc_to_frozen/deserialize with
full D-024 axis coverage (10 new optional fields on NpcSaveState).

Scope tags (#98): ScopeTagKind enum, ScopePinned marker, automatic
assignment from KnowledgeGraph and RelationshipGraph.

Timestamp eviction (#97): LastInteractionTick, SimSpacePressure,
BinaryHeap LRU eviction respecting ScopePinned entities.

Save/load (#553): save_to_file/load_from_file via MessagePack,
SaveGame/LoadGame IPC commands, SaveLoadResultWire on snapshot.

Test infrastructure (#200): Layer 3 integration test entry point,
three-layer architecture documented per D-030.

Information boundary tests (#272): 4 negative tests proving no
passive KG leakage, LOS fog holds, tier boundary holds, save
isolation per NPC.

1063 tests passing, 0 failures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 12:13:03 +01:00
co-authored by Claude Opus 4.6
parent 0dd33690f7
commit 6ed8d11502
22 changed files with 2561 additions and 35 deletions
+15
View File
@@ -80,6 +80,21 @@ impl LocalBridge {
}
impl SimBridge for LocalBridge {
fn send_handshake(&self) -> Result<(), BridgeError> {
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
let payload = rmp_serde::to_vec_named(&msg)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
write_framed(writer.get_mut(), &payload)?;
tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION);
Ok(())
}
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(snapshot)?;
+35
View File
@@ -35,6 +35,11 @@ pub enum BridgeError {
/// Abstracts transport layer (D-020)
/// Implemented by LocalBridge (stdio) and future NetworkBridge
pub trait SimBridge: Send + Sync {
/// Send the protocol handshake as the first framed message (#555).
/// Must be called exactly once, immediately after connection, before
/// any ObserverSnapshot is sent.
fn send_handshake(&self) -> Result<(), BridgeError>;
/// Send an observer snapshot to the client
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError>;
@@ -55,6 +60,10 @@ impl BridgeResource {
}
}
pub fn send_handshake(&self) -> Result<(), BridgeError> {
self.inner.send_handshake()
}
pub fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
self.inner.send_snapshot(snapshot)
}
@@ -64,15 +73,40 @@ impl BridgeResource {
}
}
/// Tracks whether the protocol handshake has been sent (#555).
/// Inserted by BridgePlugin as Pending. Set to Complete in main.rs after
/// `send_handshake()` succeeds. `receive_bridge_inputs` logs a warning
/// if inputs arrive while still Pending.
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq)]
pub enum HandshakeState {
/// Handshake not yet sent. Inputs arriving in this state trigger a warning.
Pending,
/// Handshake sent. Normal operation.
Complete,
}
impl Default for HandshakeState {
fn default() -> Self {
Self::Pending
}
}
/// Receive inputs from bridge and push to InputQueue
pub fn receive_bridge_inputs(
bridge: Option<Res<BridgeResource>>,
mut input_queue: ResMut<crate::simulation::input::InputQueue>,
mut running: ResMut<ServerRunning>,
handshake: Res<HandshakeState>,
) {
let Some(bridge) = bridge else { return };
match bridge.receive_inputs() {
Ok(inputs) => {
if !inputs.is_empty() && *handshake == HandshakeState::Pending {
tracing::warn!(
"Received {} input(s) before handshake completed — processing anyway (forward-compatible)",
inputs.len()
);
}
for input in &inputs {
tracing::trace!(
"Received input: tick={} action={:?}",
@@ -156,6 +190,7 @@ impl Plugin for BridgePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SnapshotBuffer>()
.init_resource::<ServerRunning>()
.init_resource::<HandshakeState>()
.init_resource::<crate::perception::query::VisibilityGeometry>()
.init_resource::<crate::perception::query::ActivePerceptionMode>()
.add_systems(
+20
View File
@@ -129,6 +129,26 @@ impl TcpBridge {
}
impl SimBridge for TcpBridge {
fn send_handshake(&self) -> Result<(), BridgeError> {
use super::types::{HandshakeMessage, PROTOCOL_VERSION};
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
let payload = rmp_serde::to_vec_named(&msg)?;
let mut writer = self
.writer
.lock()
.map_err(|e| BridgeError::MutexPoisoned(format!("writer: {}", e)))?;
// Toggle to blocking for reliable handshake delivery.
let stream = writer.get_mut();
stream.set_nonblocking(false).map_err(BridgeError::Io)?;
let result = write_framed(stream, &payload);
stream.set_nonblocking(true).map_err(BridgeError::Io)?;
result?;
tracing::info!("sent handshake: protocol_version={}", PROTOCOL_VERSION);
Ok(())
}
fn send_snapshot(&self, snapshot: &ObserverSnapshot) -> Result<(), BridgeError> {
let payload = rmp_serde::to_vec_named(snapshot)?;
+2 -2
View File
@@ -306,13 +306,13 @@ mod tests {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
}
}
@@ -441,13 +441,13 @@ mod tests {
conversation_events: vec![],
conversation_ended: vec![],
follow_state: None,
examine_result: None,
character_pressure: None,
sound_events: vec![],
rng_seed: None,
poi_list: vec![],
examine_result: None,
player_knowledge: None,
save_result: None,
};
let text = format_snapshot_text(&snap);
assert!(text.contains("Tick 0"));
+81 -5
View File
@@ -19,6 +19,16 @@ pub use crate::simulation::time::{DayPhase, TickRate};
/// period, then the default is removed once both sides are updated.
pub const PROTOCOL_VERSION: u8 = 14;
/// Handshake message sent as the very first framed message after connection (#555).
/// Client reads this before entering the normal tick loop and validates
/// `protocol_version` against its own `PROTOCOL_VERSION` constant.
/// Wire format: MessagePack, same 4-byte length-prefixed framing as ObserverSnapshot.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HandshakeMessage {
/// Must match client's PROTOCOL_VERSION or the client should disconnect.
pub protocol_version: u8,
}
/// The ONLY data structure crossing the client-server boundary (D-020)
/// Contains all information visible to the observer at a given tick.
///
@@ -39,6 +49,7 @@ pub const PROTOCOL_VERSION: u8 = 14;
/// v14 adds: poi_list (#151, discovered POIs for minimap rendering),
/// examine_result (#242, character-filtered examine observation text),
/// player_knowledge (#264, partial KG dump for journal/knowledge panel).
/// v15 adds: save_result (#553, save/load operation result for client confirmation).
/// Future fields: ambient sound events, HUD state (D-020 expansion).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObserverSnapshot {
@@ -110,11 +121,6 @@ pub struct ObserverSnapshot {
/// Client shows follow indicator with distance, LOS, and tension.
#[serde(default)]
pub follow_state: Option<crate::simulation::follow::FollowStateWire>,
/// Examine result from Examine verb interaction (#242).
/// Present when the player examined an NPC or object this tick.
/// Client displays character-filtered detail text in an observation panel.
#[serde(default)]
pub examine_result: Option<crate::simulation::examine::ExamineResultEvent>,
/// Character pressure state for client HUD widget (#248).
/// Present when pressure is non-zero. Client renders tension indicator.
#[serde(default)]
@@ -140,6 +146,11 @@ pub struct ObserverSnapshot {
/// Client renders as a read-only journal grouped by entity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub player_knowledge: Option<PlayerKnowledgeWire>,
/// Result of the most recently completed save or load (#553, D-085).
/// Present for exactly one tick after the operation completes.
/// Client shows a confirmation toast (success) or error modal (failure).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save_result: Option<SaveLoadResultWire>,
}
/// Game time data for client display (D-031)
@@ -414,6 +425,14 @@ pub enum PlayerAction {
target_entity_id: u64,
response_id: String,
},
/// Save the current game state to `path` (#553, D-085).
/// Client sends this when the player activates the save UI.
/// Server executes save_to_file and sends SaveLoadResultWire confirmation.
SaveGame { path: String },
/// Load a previously saved game from `path` (#553, D-085).
/// Client sends this when the player selects a save file to load.
/// Server executes load_from_file and sends SaveLoadResultWire confirmation.
LoadGame { path: String },
}
impl PlayerAction {
@@ -642,8 +661,65 @@ pub struct KnownFactWire {
pub acquired_tick: u64,
}
/// Save/load operation result for client confirmation (#553, D-085).
///
/// Included in `ObserverSnapshot.save_result` for exactly one tick after the
/// operation completes. `success=false` carries a human-readable `error` string.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SaveLoadResultWire {
/// Whether the save or load succeeded.
pub success: bool,
/// "save" or "load" — identifies which operation completed.
pub kind: String,
/// Error message if `success` is false. None on success.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// Snapshot buffer resource for staging outgoing ObserverSnapshots
#[derive(Resource, Debug, Default)]
pub struct SnapshotBuffer {
pub snapshot: Option<ObserverSnapshot>,
/// Pending save/load result, consumed once by `compute_observer_snapshot` (#553).
pub pending_save_result: Option<SaveLoadResultWire>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handshake_message_roundtrip() {
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
assert_eq!(decoded, msg);
assert_eq!(decoded.protocol_version, PROTOCOL_VERSION);
}
#[test]
fn handshake_message_rejects_wrong_version() {
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let decoded: HandshakeMessage = rmp_serde::from_slice(&bytes).expect("deserialize");
// Simulate client-side validation: version mismatch should be detectable
let wrong_version = PROTOCOL_VERSION.wrapping_add(1);
assert_ne!(decoded.protocol_version, wrong_version);
}
#[test]
fn handshake_is_distinct_from_snapshot() {
// HandshakeMessage and ObserverSnapshot are different types on the wire.
// A HandshakeMessage should NOT deserialize as an ObserverSnapshot.
let msg = HandshakeMessage {
protocol_version: PROTOCOL_VERSION,
};
let bytes = rmp_serde::to_vec_named(&msg).expect("serialize");
let result = rmp_serde::from_slice::<ObserverSnapshot>(&bytes);
assert!(result.is_err(), "HandshakeMessage must not deserialize as ObserverSnapshot");
}
}