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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Modification>,
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<StableId>,
|
||||
/// 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<Modification>,
|
||||
}
|
||||
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +418,7 @@ fn minimal_save() -> SaveStateV1 {
|
||||
template_references: TemplateReferenceMap::default(),
|
||||
triangle_states: vec![],
|
||||
open_doors: vec![],
|
||||
modifications: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user