569 lines
18 KiB
Rust
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)));
|
|
}
|
|
} |