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
+30 -4
View File
@@ -9,6 +9,7 @@ use crate::simulation::inventory::{
find_next_slot, occupied_slots_for, CarriedBy, InventorySlot, ItemName, MAX_INVENTORY_SLOTS,
};
use crate::simulation::movement::{MoveIntent, PlayerCharacter, TilePosition};
use crate::simulation::save_io::{SaveLoadCommand, SaveLoadPending};
use crate::simulation::stance::{PlayerMoveCooldown, Stance};
use crate::simulation::time::{SimulationTime, TickRate};
use crate::test_world::reset::{RoomResetTrigger, RoomSnapshots};
@@ -74,7 +75,8 @@ impl InputQueue {
}
/// Drains InputQueue for the current tick, converts PlayerActions to ECS components.
/// Handles stance toggling (D-053), movement cooldown, and Take/Place verbs (#424).
/// Handles stance toggling (D-053), movement cooldown, Take/Place verbs (#424),
/// and save/load commands (#553).
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn process_player_input(
mut input_queue: ResMut<InputQueue>,
@@ -94,6 +96,7 @@ pub fn process_player_input(
all_positions: Query<&TilePosition>,
reset_triggers: Query<&RoomResetTrigger>,
mut room_snapshots: Option<ResMut<RoomSnapshots>>,
mut save_load: Option<ResMut<SaveLoadPending>>,
) {
let current_tick = time.tick;
let paused = time.paused();
@@ -104,12 +107,15 @@ pub fn process_player_input(
for input in inputs {
// Discard all gameplay actions while paused (D-052, R2-OQ-01).
// Only Pause/Unpause/TeleportToHub are processed — everything else is discarded.
// TeleportToHub is exempted because it's a Gauntlet QA action (#491).
// SaveGame/LoadGame are also exempted — saving while paused is valid (#553).
if paused
&& !matches!(
input.action,
PlayerAction::Pause | PlayerAction::Unpause | PlayerAction::TeleportToHub
PlayerAction::Pause
| PlayerAction::Unpause
| PlayerAction::TeleportToHub
| PlayerAction::SaveGame { .. }
| PlayerAction::LoadGame { .. }
)
{
continue;
@@ -306,6 +312,26 @@ pub fn process_player_input(
PlayerAction::UsePerceptionMode(ref mode) => {
tracing::trace!("UsePerceptionMode({}) — no-op for Sprint 1", mode);
}
PlayerAction::SaveGame { ref path } => {
if let Some(ref mut sl) = save_load {
sl.pending = Some(SaveLoadCommand::Save {
path: std::path::PathBuf::from(path),
});
tracing::info!("SaveGame queued: {:?}", path);
} else {
tracing::warn!("SaveGame received but SaveLoadPending resource not registered");
}
}
PlayerAction::LoadGame { ref path } => {
if let Some(ref mut sl) = save_load {
sl.pending = Some(SaveLoadCommand::Load {
path: std::path::PathBuf::from(path),
});
tracing::info!("LoadGame queued: {:?}", path);
} else {
tracing::warn!("LoadGame received but SaveLoadPending resource not registered");
}
}
}
}