From 61eb40fcab5db7024484aaf97f7a932daabd0fe7 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Sat, 28 Feb 2026 16:34:08 +0100 Subject: [PATCH] feat(simulation): add modifications data model stub (#567, D-112) DLC entry point for future player construction system. Adds Modification struct, ModificationType enum, and Modifications component. Wired into SaveStateV1 with #[serde(default)] for forward-compatible save format. Co-Authored-By: Claude Opus 4.6 --- server/src/simulation/modification.rs | 114 ++++++++++++++++++++++ server/src/simulation/save_io.rs | 2 + server/src/simulation/save_state.rs | 64 ++++++++++++ server/tests/environmental_interaction.rs | 1 + server/tests/information_boundaries.rs | 1 + 5 files changed, 182 insertions(+) create mode 100644 server/src/simulation/modification.rs diff --git a/server/src/simulation/modification.rs b/server/src/simulation/modification.rs new file mode 100644 index 000000000..940028b4d --- /dev/null +++ b/server/src/simulation/modification.rs @@ -0,0 +1,114 @@ +//! Modification data model stub (D-111/D-112, #567). +//! +//! Entry point for the future player construction system (DLC scope). +//! Tracks player-placed modifications to static and mobile chunks. +//! No construction logic ships in v0.1 — this is a data model stub only. +//! +//! The `Modifications` component can be attached to any entity that represents +//! a spatial chunk (static or mobile). Currently unused at runtime; the +//! `SaveStateV1` field ensures the data model round-trips through save/load +//! so future DLC can populate it without a save format migration. + +use bevy_ecs::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::simulation::movement::TilePosition; + +/// A single player-placed modification to a chunk. +/// +/// Records what was placed, where, and when. The `modification_type` enum +/// will be extended by the construction DLC — the stub contains only a +/// `Placeholder` variant to keep the enum non-empty. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Modification { + /// Tile position of this modification within the chunk. + pub position: TilePosition, + /// What kind of modification was placed. + pub modification_type: ModificationType, + /// Simulation tick when this modification was placed. + pub placed_at_tick: u64, +} + +/// Type of modification placed by the player. +/// +/// Stub enum — will be extended by the construction DLC with variants +/// like `Wall`, `Floor`, `Furniture`, `Terminal`, etc. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ModificationType { + /// Placeholder variant. Prevents the enum from being uninhabited + /// and allows save format round-tripping before real variants ship. + Placeholder, +} + +/// Component: player-placed modifications on a chunk entity. +/// +/// Attach to any entity that represents a modifiable spatial region +/// (static chunk, mobile chunk, etc.). Initially empty for all entities. +#[derive(Component, Debug, Clone, Default, Serialize, Deserialize)] +pub struct Modifications { + pub entries: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn modification_type_placeholder_serializes() { + let mod_type = ModificationType::Placeholder; + let bytes = rmp_serde::to_vec_named(&mod_type).expect("serialize"); + let recovered: ModificationType = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(mod_type, recovered); + } + + #[test] + fn modification_roundtrips_via_messagepack() { + let modification = Modification { + position: TilePosition::new(5, 10, 0), + modification_type: ModificationType::Placeholder, + placed_at_tick: 42, + }; + + let bytes = rmp_serde::to_vec_named(&modification).expect("serialize"); + let recovered: Modification = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(modification, recovered); + } + + #[test] + fn modifications_component_defaults_to_empty() { + let mods = Modifications::default(); + assert!(mods.entries.is_empty()); + } + + #[test] + fn modifications_with_entries_roundtrips() { + let mods = Modifications { + entries: vec![ + Modification { + position: TilePosition::new(1, 2, 0), + modification_type: ModificationType::Placeholder, + placed_at_tick: 100, + }, + Modification { + position: TilePosition::new(3, 4, 1), + modification_type: ModificationType::Placeholder, + placed_at_tick: 200, + }, + ], + }; + + let bytes = rmp_serde::to_vec_named(&mods).expect("serialize"); + let recovered: Modifications = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(mods.entries.len(), recovered.entries.len()); + assert_eq!(mods.entries[0], recovered.entries[0]); + assert_eq!(mods.entries[1], recovered.entries[1]); + } + + #[test] + fn empty_modifications_roundtrips() { + let mods = Modifications::default(); + let bytes = rmp_serde::to_vec_named(&mods).expect("serialize"); + let recovered: Modifications = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert!(recovered.entries.is_empty()); + } +} diff --git a/server/src/simulation/save_io.rs b/server/src/simulation/save_io.rs index 16519a59f..a25cf28af 100644 --- a/server/src/simulation/save_io.rs +++ b/server/src/simulation/save_io.rs @@ -143,6 +143,7 @@ pub fn save_to_file(path: &Path, world: &mut World) -> Result<(), SaveLoadError> ids.sort_by_key(|id| id.0); ids }, + modifications: vec![], }; let bytes = state @@ -558,6 +559,7 @@ mod tests { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], }; let bytes = bad_state.to_bytes().expect("serialize"); let path = temp_path(); diff --git a/server/src/simulation/save_state.rs b/server/src/simulation/save_state.rs index 5beddc540..7780fbd25 100644 --- a/server/src/simulation/save_state.rs +++ b/server/src/simulation/save_state.rs @@ -43,6 +43,7 @@ use crate::content::template::{TemplateOwnership, TemplateReferenceMap, Triangle use crate::knowledge::graph::KnowledgeGraph; use crate::knowledge::registry::StableEntityId; use crate::knowledge::types::StableId; +use crate::simulation::modification::Modification; use crate::npc::{ CombatCapability, Contentment, DailyRoutine, InformationInventory, JobPerformance, Npc, PersonalityTraits, Relationships, Secret, SecretSeverity, SkillSet, TellSystem, @@ -99,6 +100,11 @@ pub struct SaveStateV1 { /// for deterministic serialization (D-010). #[serde(default)] pub open_doors: Vec, + /// Player-placed modifications to map chunks (D-111/D-112, #567). + /// DLC stub — empty in v0.1. The save slot exists so future construction + /// DLC can populate it without a save format migration. + #[serde(default)] + pub modifications: Vec, } /// Per-NPC state snapshot for `SaveStateV1`. @@ -411,6 +417,7 @@ mod tests { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], } } @@ -808,6 +815,7 @@ mod tests { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], }; let bytes = save.to_bytes().expect("serialize"); @@ -828,4 +836,60 @@ mod tests { })); assert!(result.is_err(), "must panic without StableEntityId"); } + + // ----------------------------------------------------------------------- + // Modifications stub round-trip (#567, D-111/D-112) + // ----------------------------------------------------------------------- + + #[test] + fn empty_modifications_roundtrips_in_save_state() { + // Acceptance (#567): empty modifications field survives save/load. + let state = minimal_save_state(); + assert!(state.modifications.is_empty()); + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + assert!( + recovered.modifications.is_empty(), + "empty modifications must roundtrip" + ); + + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "modifications roundtrip must be idempotent"); + } + + #[test] + fn populated_modifications_roundtrips_in_save_state() { + // Acceptance (#567): non-empty modifications field survives save/load. + use crate::simulation::modification::{ModificationType, Modification}; + + let mut state = minimal_save_state(); + state.modifications = vec![ + Modification { + position: TilePosition::new(10, 20, 0), + modification_type: ModificationType::Placeholder, + placed_at_tick: 500, + }, + Modification { + position: TilePosition::new(3, 7, -1), + modification_type: ModificationType::Placeholder, + placed_at_tick: 1200, + }, + ]; + + let bytes = state.to_bytes().expect("serialize"); + let recovered = SaveStateV1::from_bytes(&bytes).expect("deserialize"); + assert_eq!( + recovered.modifications.len(), + 2, + "two modifications must survive roundtrip" + ); + assert_eq!(recovered.modifications[0].position, TilePosition::new(10, 20, 0)); + assert_eq!(recovered.modifications[0].placed_at_tick, 500); + assert_eq!(recovered.modifications[1].position, TilePosition::new(3, 7, -1)); + assert_eq!(recovered.modifications[1].placed_at_tick, 1200); + + let bytes2 = recovered.to_bytes().expect("re-serialize"); + assert_eq!(bytes, bytes2, "modifications roundtrip must be idempotent"); + } } diff --git a/server/tests/environmental_interaction.rs b/server/tests/environmental_interaction.rs index f3d2b72b1..f9cf7d59c 100644 --- a/server/tests/environmental_interaction.rs +++ b/server/tests/environmental_interaction.rs @@ -418,6 +418,7 @@ fn minimal_save() -> SaveStateV1 { template_references: TemplateReferenceMap::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], } } diff --git a/server/tests/information_boundaries.rs b/server/tests/information_boundaries.rs index 27e6c134a..59e56152e 100644 --- a/server/tests/information_boundaries.rs +++ b/server/tests/information_boundaries.rs @@ -206,6 +206,7 @@ fn save_state_npc_kg_isolation() { template_references: Default::default(), triangle_states: vec![], open_doors: vec![], + modifications: vec![], }; // Roundtrip: serialize → deserialize.