Files
dungeoncraft/src/campaign_doc.rs
T

569 lines
18 KiB
Rust

// 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<bool>,
pub completed_choice: Vec<Option<String>>,
}
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<String>) {
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<MissionDoc>,
}
#[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<String>,
#[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<String>,
#[serde(default)]
pub branches: Vec<BranchDoc>,
#[serde(default)]
pub zones: Vec<ZoneDoc>,
#[serde(default)]
pub script: Option<ScriptDoc>,
}
#[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<u32>,
}
#[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<f32>,
#[serde(default)]
pub tiles: Option<Vec<(i32, i32)>>,
#[serde(default)]
pub from: Option<(f32, f32)>,
#[serde(default)]
pub to: Option<(f32, f32)>,
#[serde(default)]
pub width: Option<f32>,
}
#[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<CampaignDoc, String> {
serde_json::from_str::<CampaignDoc>(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<String> {
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<F>(&self, i: usize, goal_done: F) -> (Option<usize>, Option<String>)
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<String> {
text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
}
pub fn parse_branches(text: &str) -> Vec<BranchDoc> {
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::<u32>().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::<Vec<_>>()
.join("; ")
}
pub fn parse_goals(raws: &[String]) -> Vec<Goal> {
raws.iter().filter_map(|r| parse_goal(r)).collect()
}
pub fn parse_goal(raw: &str) -> Option<Goal> {
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<u32> {
let rest = s.strip_prefix(prefix)?;
if !rest.starts_with('(') || !rest.ends_with(')') {
return None;
}
rest[1..rest.len() - 1].trim().parse::<u32>().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)));
}
}