822 lines
28 KiB
Rust
822 lines
28 KiB
Rust
// 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<bool>,
|
|
pub completed_choice: Vec<Option<String>>,
|
|
// 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<usize>,
|
|
#[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<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>,
|
|
// 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<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>, // 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<Block>, // once when the mission begins
|
|
#[serde(default)]
|
|
pub victory: Vec<Block>, // every second: may call win
|
|
#[serde(default)]
|
|
pub defeat: Vec<Block>, // every second: may call lose
|
|
#[serde(default)]
|
|
pub tick: Vec<Block>, // every second
|
|
#[serde(default)]
|
|
pub triggers: Vec<TriggerDoc>,
|
|
}
|
|
|
|
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<Block>,
|
|
}
|
|
|
|
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<TriggerKind> {
|
|
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<u32>,
|
|
}
|
|
|
|
#[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<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: "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<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;
|
|
// 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<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()));
|
|
}
|
|
|
|
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<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)
|
|
}
|
|
}
|
|
|
|
impl MissionDoc {
|
|
pub fn zone_names(&self) -> Vec<String> {
|
|
self.zones.iter().map(|z| z.name.clone()).collect()
|
|
}
|
|
|
|
pub fn trigger_names(&self) -> Vec<String> {
|
|
self.scripts.triggers.iter().map(|t| t.name.clone()).collect()
|
|
}
|
|
|
|
// every variable some script of the mission sets
|
|
pub fn vars(&self) -> Vec<String> {
|
|
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<crate::blocks::Problem> {
|
|
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<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::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)));
|
|
}
|
|
} |