// Data model for shareable campaign files (.dccamp). // This is the first editor layer: JSON missions, branch goals, scripts and trigger zones. // The gameplay-side campaign start for custom missions comes in a later step. use crate::game::Goal; use serde::{Deserialize, Serialize}; use std::collections::HashSet; pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign"; pub const CAMPAIGN_VERSION: u32 = 1; pub const CAMPAIGN_EXTENSION: &str = "dccamp"; #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct CampaignProgress { pub done: Vec, pub completed_choice: Vec>, } impl CampaignProgress { pub fn for_doc(doc: &CampaignDoc) -> CampaignProgress { let n = doc.missions.len(); CampaignProgress { done: vec![false; n], completed_choice: vec![None; n], } } pub fn ensure(&mut self, n: usize) { self.done.resize(n, false); self.completed_choice.resize(n, None); } pub fn is_done(&self, i: usize) -> bool { self.done.get(i).copied().unwrap_or(false) } pub fn mark_done(&mut self, i: usize, n: usize, choice: Option) { self.ensure(n); if let Some(d) = self.done.get_mut(i) { *d = true; } if let Some(c) = self.completed_choice.get_mut(i) { *c = choice; } } } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CampaignDoc { #[serde(default = "default_format")] pub format: String, #[serde(default = "default_version")] pub version: u32, #[serde(default)] pub name: String, #[serde(default)] pub author: String, #[serde(default)] pub missions: Vec, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MissionDoc { #[serde(default)] pub key: String, #[serde(default)] pub title: String, #[serde(default)] pub story: String, #[serde(default = "default_size")] pub size: (usize, usize), #[serde(default)] pub seed: u32, #[serde(default = "default_gold")] pub gold: f32, #[serde(default = "default_portals")] pub portals: usize, #[serde(default)] pub treasures: usize, #[serde(default)] pub goals: Vec, #[serde(default = "default_false")] pub waves: bool, #[serde(default)] pub wave_limit: u32, #[serde(default = "default_first_wave")] pub first_wave: f32, #[serde(default)] pub time_limit: f32, #[serde(default)] pub next: Option, #[serde(default)] pub branches: Vec, #[serde(default)] pub zones: Vec, #[serde(default)] pub script: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct BranchDoc { #[serde(default)] pub id: String, #[serde(default)] pub goal: String, #[serde(default)] pub title: String, #[serde(default)] pub next: String, #[serde(default)] pub priority: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ZoneDoc { #[serde(default)] pub name: String, #[serde(default = "default_shape")] pub shape: String, #[serde(default)] pub at: Option<(f32, f32)>, #[serde(default)] pub radius: Option, #[serde(default)] pub tiles: Option>, #[serde(default)] pub from: Option<(f32, f32)>, #[serde(default)] pub to: Option<(f32, f32)>, #[serde(default)] pub width: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ScriptDoc { #[serde(default = "default_script_language")] pub language: String, #[serde(default)] pub source: String, } impl Default for CampaignDoc { fn default() -> Self { CampaignDoc { format: default_format(), version: default_version(), name: "Neue Kampagne".into(), author: String::new(), missions: Vec::new(), } } } impl Default for MissionDoc { fn default() -> Self { MissionDoc { key: String::new(), title: String::new(), story: String::new(), size: default_size(), seed: 0, gold: default_gold(), portals: default_portals(), treasures: 0, goals: Vec::new(), waves: false, wave_limit: 0, first_wave: default_first_wave(), time_limit: 0.0, next: None, branches: Vec::new(), zones: Vec::new(), script: None, } } } impl CampaignDoc { pub fn load(text: &str) -> Result { serde_json::from_str::(text).map_err(|e| format!("invalid campaign file: {e}")) } pub fn save(&self) -> String { serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".into()) } pub fn add_mission(&mut self) -> &mut MissionDoc { let n = self.missions.len() + 1; self.missions.push(MissionDoc { key: format!("m{n:02}"), title: format!("Mission {n}"), seed: (n as u32) * 101, goals: vec!["Shards".into()], ..Default::default() }); self.missions.last_mut().expect("the new mission is last") } pub fn file_name(&self) -> String { let base: String = self .name .chars() .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') .collect(); let base = if base.is_empty() { "campaign".to_string() } else { base }; format!("{}.{}", base, CAMPAIGN_EXTENSION) } pub fn validate(&self) -> Vec { let mut errors = Vec::new(); let mut keys = HashSet::new(); if self.missions.is_empty() { errors.push("campaign has no missions".into()); } for mission in &self.missions { if !valid_key(&mission.key) { errors.push(format!("invalid mission key: {:?}", mission.key)); } else if !keys.insert(mission.key.clone()) { errors.push(format!("duplicate mission key: {}", mission.key)); } if mission.title.trim().is_empty() { errors.push(format!("mission {} has no title", mission.key)); } if mission.size.0 < 32 || mission.size.1 < 32 || mission.size.0 > 512 || mission.size.1 > 512 { errors.push(format!("mission {} has an out-of-range size: {}x{}", mission.key, mission.size.0, mission.size.1)); } for raw in &mission.goals { if parse_goal(raw).is_none() { errors.push(format!("mission {} has an unknown goal: {}", mission.key, raw)); } } if let Some(next) = &mission.next { if !valid_key(next) { errors.push(format!("mission {} has an invalid next mission: {:?}", mission.key, next)); } } if mission.next.as_deref().map_or(false, |next| !self.missions.iter().any(|m| m.key == next)) { errors.push(format!("mission {} has an unknown next mission: {}", mission.key, mission.next.clone().unwrap_or_default())); } for branch in &mission.branches { if !valid_key(&branch.id) { errors.push(format!("mission {} has an invalid branch id: {:?}", mission.key, branch.id)); } if parse_goal(&branch.goal).is_none() { errors.push(format!("mission {} branch {} has an unknown goal: {}", mission.key, branch.id, branch.goal)); } if !valid_key(&branch.next) { errors.push(format!("mission {} branch {} has an invalid next mission: {:?}", mission.key, branch.id, branch.next)); } else if !self.missions.iter().any(|m| m.key == branch.next) { errors.push(format!("mission {} branch {} points to an unknown mission: {}", mission.key, branch.id, branch.next)); } } } for mission in &self.missions { if let Some(next) = &mission.next { if !self.missions.iter().any(|m| m.key == *next) { errors.push(format!("mission {} next mission does not exist: {}", mission.key, next)); } } } errors } pub fn next_mission_choice(&self, i: usize, goal_done: F) -> (Option, Option) where F: Fn(Goal) -> bool, { let Some(mission) = self.missions.get(i) else { return (None, None); }; let mut matched: Vec<&BranchDoc> = mission .branches .iter() .filter(|b| match parse_goal(&b.goal) { Some(g) => goal_done(g), None => false, }) .collect(); matched.sort_by(|a, b| b.priority.unwrap_or(0).cmp(&a.priority.unwrap_or(0))); if let Some(branch) = matched.first() { return (self.missions.iter().position(|m| m.key == branch.next), Some(branch.id.clone())); } let main_done = !mission.goals.is_empty() && mission .goals .iter() .filter_map(|g| parse_goal(g)) .all(|g| goal_done(g)); if main_done { if let Some(next) = &mission.next { return (self.missions.iter().position(|m| m.key == *next), None); } } if let Some(next) = &mission.next { return (self.missions.iter().position(|m| m.key == *next), None); } if mission.branches.is_empty() && i + 1 < self.missions.len() { return (Some(i + 1), None); } (None, None) } } pub fn parse_list(text: &str) -> Vec { text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() } pub fn parse_branches(text: &str) -> Vec { text.split(';') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| { let parts: Vec<&str> = s.split(':').map(|p| p.trim()).collect(); BranchDoc { id: parts.first().copied().unwrap_or_default().to_string(), goal: parts.get(1).copied().unwrap_or_default().to_string(), next: parts.get(2).copied().unwrap_or_default().to_string(), priority: parts.get(3).and_then(|p| p.parse::().ok()), title: parts.get(4).copied().unwrap_or_default().to_string(), } }) .collect() } pub fn branches_to_text(branches: &[BranchDoc]) -> String { branches .iter() .map(|b| { let mut line = format!("{}:{}:{}", b.id, b.goal, b.next); if b.priority.is_some() || !b.title.is_empty() { line.push(':'); line.push_str(&b.priority.unwrap_or(0).to_string()); } if !b.title.is_empty() { line.push(':'); line.push_str(&b.title); } line }) .collect::>() .join("; ") } pub fn parse_goals(raws: &[String]) -> Vec { raws.iter().filter_map(|r| parse_goal(r)).collect() } pub fn parse_goal(raw: &str) -> Option { let s = raw.trim(); match s { "Shards" => return Some(Goal::Shards), "Heart" => return Some(Goal::Heart), "KillAll" => return Some(Goal::KillAll), "Towers" => return Some(Goal::Towers), "Rivals" => return Some(Goal::Rivals), _ => {} } if let Some(n) = number_after_prefix(s, "Loot") { return Some(Goal::Loot(n)); } if let Some(n) = number_after_prefix(s, "Survive") { return Some(Goal::Survive(n)); } if let Some(n) = number_after_prefix(s, "Springs") { return Some(Goal::Springs(n)); } if let Some(n) = number_after_prefix(s, "Freed") { return Some(Goal::Freed(n)); } None } fn number_after_prefix(s: &str, prefix: &str) -> Option { let rest = s.strip_prefix(prefix)?; if !rest.starts_with('(') || !rest.ends_with(')') { return None; } rest[1..rest.len() - 1].trim().parse::().ok() } fn valid_key(s: &str) -> bool { !s.is_empty() && s.len() <= 48 && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } fn default_format() -> String { CAMPAIGN_FORMAT.into() } fn default_version() -> u32 { CAMPAIGN_VERSION } fn default_size() -> (usize, usize) { (96, 80) } fn default_gold() -> f32 { 3000.0 } fn default_portals() -> usize { 1 } fn default_first_wave() -> f32 { 300.0 } fn default_false() -> bool { false } fn default_shape() -> String { "circle".into() } fn default_script_language() -> String { "rhai".into() } #[cfg(test)] mod tests { use super::*; #[test] fn a_campaign_file_reads_missions_branches_and_scripts() { let text = r#"{ "format": "dungeoncraft.campaign", "version": 1, "name": "Meine Kampagne", "author": "Tester", "missions": [ { "key": "my01", "title": "Der erste Weg", "size": [96, 80], "seed": 123, "goals": ["Shards"], "branches": [ { "id": "kill_path", "goal": "KillAll", "next": "my02a" }, { "id": "loot_path", "goal": "Loot(3000)", "next": "my02b", "priority": 2 } ], "script": { "language": "rhai", "source": "fn on_start(m) { m.log('hi'); }" } }, { "key": "my02a", "title": "A", "size": [96, 80] }, { "key": "my02b", "title": "B", "size": [96, 80] } ] }"#; let campaign = CampaignDoc::load(text).expect("the campaign file parses"); assert_eq!(campaign.name, "Meine Kampagne"); assert_eq!(campaign.missions[0].branches.len(), 2); assert_eq!(campaign.missions[0].branches[1].priority, Some(2)); assert_eq!(parse_goal(campaign.missions[0].branches[1].goal.as_str()), Some(Goal::Loot(3000))); assert!(campaign.validate().is_empty()); } #[test] fn branch_goals_choose_the_next_mission() { let campaign = CampaignDoc { name: "Branches".into(), missions: vec![ MissionDoc { key: "m01".into(), title: "Start".into(), branches: vec![BranchDoc { id: "kill".into(), goal: "KillAll".into(), next: "m02a".into(), priority: Some(1), ..Default::default() }], ..Default::default() }, MissionDoc { key: "m02a".into(), title: "A".into(), ..Default::default() }, ], ..Default::default() }; let (next, choice) = campaign.next_mission_choice(0, |goal| goal == Goal::KillAll); assert_eq!(next, Some(1)); assert_eq!(choice.as_deref(), Some("kill")); } #[test] fn higher_priority_branch_wins() { let campaign = CampaignDoc { missions: vec![ MissionDoc { key: "m01".into(), title: "Start".into(), branches: vec![ BranchDoc { id: "low".into(), goal: "Shards".into(), next: "m02low".into(), priority: Some(1), ..Default::default() }, BranchDoc { id: "high".into(), goal: "Shards".into(), next: "m02high".into(), priority: Some(2), ..Default::default() }, ], ..Default::default() }, MissionDoc { key: "m02low".into(), title: "Low".into(), ..Default::default() }, MissionDoc { key: "m02high".into(), title: "High".into(), ..Default::default() }, ], ..Default::default() }; let (next, choice) = campaign.next_mission_choice(0, |goal| goal == Goal::Shards); assert_eq!(next, Some(2)); assert_eq!(choice.as_deref(), Some("high")); } #[test] fn a_new_campaign_starts_with_one_playable_mission() { let mut campaign = CampaignDoc::default(); campaign.add_mission(); assert_eq!(campaign.missions.len(), 1); assert_eq!(campaign.missions[0].key, "m01"); assert_eq!(parse_goal(campaign.missions[0].goals[0].as_str()), Some(Goal::Shards)); assert!(campaign.validate().is_empty()); } #[test] fn branch_text_round_trips_branch_objects() { let branches = parse_branches("kill:KillAll:m02a:1:Kill path; loot:Loot(3000):m02b:2:Plunder path"); assert_eq!(branches.len(), 2); assert_eq!(branches[0].id, "kill"); assert_eq!(branches[0].priority, Some(1)); assert_eq!(branches[0].title, "Kill path"); assert_eq!(parse_goal(branches[1].goal.as_str()), Some(Goal::Loot(3000))); let text = branches_to_text(&branches); assert_eq!(parse_branches(&text), branches); } #[test] fn a_bad_branch_next_is_reported() { let campaign = CampaignDoc { missions: vec![MissionDoc { key: "my01".into(), title: "Mission".into(), branches: vec![BranchDoc { id: "bad".into(), goal: "Shards".into(), next: "missing".into(), ..Default::default() }], ..Default::default() }], ..Default::default() }; let errors = campaign.validate(); assert!(errors.iter().any(|e| e.contains("points to an unknown mission"))); } #[test] fn numbered_goals_parse_into_real_game_goals() { assert_eq!(parse_goal("Survive(4)"), Some(Goal::Survive(4))); assert_eq!(parse_goal("Springs(2)"), Some(Goal::Springs(2))); assert_eq!(parse_goal("Freed(1)"), Some(Goal::Freed(1))); } }