// Data model for shareable campaign files (.dccamp): missions with their map (a fixed, // painted map or generator settings for a random map), goals and branches, trigger zones, // and the block scripts (special scripts and triggers, see blocks.rs and mscript.rs). use crate::blocks::Block; use crate::game::Goal; use serde::{Deserialize, Serialize}; use std::collections::HashSet; pub const CAMPAIGN_FORMAT: &str = "dungeoncraft.campaign"; pub const CAMPAIGN_VERSION: u32 = 2; // 2: maps, block scripts and triggers pub const CAMPAIGN_EXTENSION: &str = "dccamp"; #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct CampaignProgress { pub done: Vec, pub completed_choice: Vec>, // random maps are rolled once per run of the campaign: this seed is fixed for the run #[serde(default)] pub run_seed: u32, // co-op runs (kept by the host): the mission the run goes on with and what every master carries // into it; None once the run is over #[serde(default)] pub next: Option, #[serde(default)] pub carry: Vec<(u8, crate::campaign::Carry)>, } impl CampaignProgress { pub fn for_doc(doc: &CampaignDoc) -> CampaignProgress { let n = doc.missions.len(); CampaignProgress { done: vec![false; n], completed_choice: vec![None; n], run_seed: (macroquad::miniquad::date::now() * 1000.0) as u64 as u32 | 1, next: None, carry: Vec::new(), } } 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, // the one language the author writes the texts in (a language code) #[serde(default)] pub language: String, } #[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, // version 1 text scripts (no longer run) #[serde(default)] pub map: MapDoc, #[serde(default)] pub gen: GenDoc, #[serde(default)] pub scripts: MissionScripts, // position of the mission's card in the campaign graph #[serde(default)] pub pos: (f32, f32), } // the map of a mission: generated anew for every run of the campaign, or painted and fixed #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind")] pub enum MapDoc { #[default] Random, // the whole game state of the edited map as savegame JSON (tiles packed) Fixed { game: String }, } // generator settings (random maps, and the start of a painted map) #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct GenDoc { #[serde(default)] pub rivers: u8, // 0..3 water rivers #[serde(default)] pub lava: u8, // 0..2 lava rivers #[serde(default)] pub forts: u8, // 0..3 knight fortresses #[serde(default)] pub rivals: u8, // 0..2 rival masters #[serde(default = "default_difficulty")] pub difficulty: u8, // 0 easy, 1 medium, 2 hard #[serde(default)] pub surface: bool, // the eastern half lies above ground (forest) } impl Default for GenDoc { fn default() -> Self { GenDoc { rivers: 1, lava: 0, forts: 1, rivals: 0, difficulty: 1, surface: false } } } fn default_difficulty() -> u8 { 1 } // the block scripts of a mission #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] pub struct MissionScripts { #[serde(default)] pub start: Vec, // once when the mission begins #[serde(default)] pub victory: Vec, // every second: may call win #[serde(default)] pub defeat: Vec, // every second: may call lose #[serde(default)] pub tick: Vec, // every second #[serde(default)] pub triggers: Vec, } impl MissionScripts { pub fn is_empty(&self) -> bool { self.start.is_empty() && self.victory.is_empty() && self.defeat.is_empty() && self.tick.is_empty() && self.triggers.is_empty() } } // a trigger: a condition (set up in a form) and the blocks that run when it is met #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] pub struct TriggerDoc { pub name: String, pub kind: TriggerKind, #[serde(default = "default_true")] pub once: bool, #[serde(default = "default_true")] pub enabled: bool, // active from the start (blocks can switch triggers on and off) #[serde(default)] pub script: Vec, } fn default_true() -> bool { true } // what sets a trigger off. side: -1 any, 0 player, 1 heroes, 2-4 rival masters; kind "" = any #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "t")] pub enum TriggerKind { ZoneEnter { zone: String, side: i32, kind: String }, ZoneLeave { zone: String, side: i32, kind: String }, LineCross { zone: String, side: i32, kind: String }, ZoneClaimed { zone: String }, ZoneDug { zone: String }, ZoneCount { zone: String, side: i32, kind: String, op: String, n: f64 }, Timer { secs: f64, every: bool }, UnitDies { side: i32, kind: String, #[serde(default)] name: String }, TowerFalls { side: i32 }, // 0 the dungeon's towers, 1 the knights' towers, -1 any Value { what: String, #[serde(default)] var: String, op: String, n: f64 }, // what: gold, mana, creatures, heroes, var RoomBuilt { room: String }, // a room kind ("Lair", ...) or "" for any SpellCast { spell: String }, // a spell ("Lightning", ...) or "" for any Slap, Research, } // trigger kinds in the order of the editor's list pub const TRIGGER_KINDS: [&str; 14] = ["zone_enter", "zone_leave", "line_cross", "zone_claimed", "zone_dug", "zone_count", "timer", "unit_dies", "tower_falls", "value", "room_built", "spell_cast", "slap", "research"]; impl TriggerKind { pub fn template(kind: &str) -> Option { let z = String::new; Some(match kind { "zone_enter" => TriggerKind::ZoneEnter { zone: z(), side: 0, kind: z() }, "zone_leave" => TriggerKind::ZoneLeave { zone: z(), side: 0, kind: z() }, "line_cross" => TriggerKind::LineCross { zone: z(), side: -1, kind: z() }, "zone_claimed" => TriggerKind::ZoneClaimed { zone: z() }, "zone_dug" => TriggerKind::ZoneDug { zone: z() }, "zone_count" => TriggerKind::ZoneCount { zone: z(), side: 1, kind: z(), op: ">=".into(), n: 3.0 }, "timer" => TriggerKind::Timer { secs: 60.0, every: false }, "unit_dies" => TriggerKind::UnitDies { side: 1, kind: z(), name: z() }, "tower_falls" => TriggerKind::TowerFalls { side: 1 }, "value" => TriggerKind::Value { what: "gold".into(), var: z(), op: ">=".into(), n: 5000.0 }, "room_built" => TriggerKind::RoomBuilt { room: z() }, "spell_cast" => TriggerKind::SpellCast { spell: z() }, "slap" => TriggerKind::Slap, "research" => TriggerKind::Research, _ => return None, }) } pub fn kind_name(&self) -> &'static str { match self { TriggerKind::ZoneEnter { .. } => "zone_enter", TriggerKind::ZoneLeave { .. } => "zone_leave", TriggerKind::LineCross { .. } => "line_cross", TriggerKind::ZoneClaimed { .. } => "zone_claimed", TriggerKind::ZoneDug { .. } => "zone_dug", TriggerKind::ZoneCount { .. } => "zone_count", TriggerKind::Timer { .. } => "timer", TriggerKind::UnitDies { .. } => "unit_dies", TriggerKind::TowerFalls { .. } => "tower_falls", TriggerKind::Value { .. } => "value", TriggerKind::RoomBuilt { .. } => "room_built", TriggerKind::SpellCast { .. } => "spell_cast", TriggerKind::Slap => "slap", TriggerKind::Research => "research", } } // the zone a trigger watches, if any pub fn zone(&self) -> Option<&str> { match self { TriggerKind::ZoneEnter { zone, .. } | TriggerKind::ZoneLeave { zone, .. } | TriggerKind::LineCross { zone, .. } | TriggerKind::ZoneClaimed { zone } | TriggerKind::ZoneDug { zone } | TriggerKind::ZoneCount { zone, .. } => Some(zone), _ => None, } } } #[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, PartialEq)] 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: "Campaign".into(), author: String::new(), missions: Vec::new(), language: String::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, map: MapDoc::Random, gen: GenDoc::default(), scripts: MissionScripts::default(), pos: (0.0, 0.0), } } } 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; // a free key (missions can be deleted, so the count alone may be taken) let mut k = n; while self.missions.iter().any(|m| m.key == format!("m{k:02}")) { k += 1; } self.missions.push(MissionDoc { key: format!("m{k:02}"), title: format!("Mission {n}"), seed: (k as u32) * 101, goals: vec!["KillAll".into()], pos: (40.0 + ((n - 1) % 5) as f32 * 220.0, 40.0 + ((n - 1) / 5) as f32 * 140.0), ..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())); } let mut zone_names = HashSet::new(); for z in &mission.zones { if z.name.trim().is_empty() || !zone_names.insert(z.name.clone()) { errors.push(format!("mission {} has an unnamed or duplicate zone: {:?}", mission.key, z.name)); } } let mut trig_names = HashSet::new(); for t in &mission.scripts.triggers { if t.name.trim().is_empty() || !trig_names.insert(t.name.clone()) { errors.push(format!("mission {} has an unnamed or duplicate trigger: {:?}", mission.key, t.name)); } } for (what, n) in mission.script_problems() { errors.push(format!("mission {} {}: {} problem(s)", mission.key, what, n)); } 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) } } impl MissionDoc { pub fn zone_names(&self) -> Vec { self.zones.iter().map(|z| z.name.clone()).collect() } pub fn trigger_names(&self) -> Vec { self.scripts.triggers.iter().map(|t| t.name.clone()).collect() } // every variable some script of the mission sets pub fn vars(&self) -> Vec { let mut v = Vec::new(); let s = &self.scripts; for list in [&s.start, &s.victory, &s.defeat, &s.tick] { crate::blocks::vars_set(list, &mut v); } for t in &s.triggers { crate::blocks::vars_set(&t.script, &mut v); } v } // problems in a list of blocks of this mission pub fn problems(&self, list: &[Block]) -> Vec { let (zones, triggers, vars) = (self.zone_names(), self.trigger_names(), self.vars()); crate::blocks::check(list, &crate::blocks::Names { zones: &zones, triggers: &triggers, vars: &vars }) } // (script name, number of problems) for every script with problems, and triggers whose // zone is missing pub fn script_problems(&self) -> Vec<(String, usize)> { let mut out = Vec::new(); let s = &self.scripts; for (name, list) in [("start", &s.start), ("victory", &s.victory), ("defeat", &s.defeat), ("tick", &s.tick)] { let n = self.problems(list).len(); if n > 0 { out.push((name.to_string(), n)); } } let zones = self.zone_names(); for t in &s.triggers { let mut n = self.problems(&t.script).len(); if t.kind.zone().map_or(false, |z| !zones.iter().any(|x| x == z)) { n += 1; } if n > 0 { out.push((format!("trigger {}", t.name), n)); } } out } } 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::KillAll)); 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))); } }