use crate::envelope::Campaign;
use crate::fight::Fight;
use crate::stages::{Assembly, EnvTrigger, Loop, Quest, QuestEffect, Shop, Shortcut, Trap};
fn local(id: &str) -> &str {
id.split_once('/').map(|(_, r)| r).unwrap_or(id)
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug, Hash)]
pub enum EffectRootKind {
ObjectiveComplete,
QuestComplete,
Trigger,
TrapPayload,
DialogueRespawn,
ShortcutUnlock,
OnDeath,
ShopOffer,
OnKill,
AssemblyLand,
LoopCross,
}
impl EffectRootKind {
pub const ALL: [EffectRootKind; 11] = [
EffectRootKind::ObjectiveComplete,
EffectRootKind::QuestComplete,
EffectRootKind::Trigger,
EffectRootKind::TrapPayload,
EffectRootKind::DialogueRespawn,
EffectRootKind::ShortcutUnlock,
EffectRootKind::OnDeath,
EffectRootKind::ShopOffer,
EffectRootKind::OnKill,
EffectRootKind::AssemblyLand,
EffectRootKind::LoopCross,
];
pub const COUNT: usize = Self::ALL.len();
pub fn stage(self) -> &'static str {
match self {
EffectRootKind::ObjectiveComplete
| EffectRootKind::QuestComplete
| EffectRootKind::Trigger
| EffectRootKind::TrapPayload
| EffectRootKind::ShortcutUnlock
| EffectRootKind::OnDeath
| EffectRootKind::ShopOffer
| EffectRootKind::OnKill
| EffectRootKind::AssemblyLand
| EffectRootKind::LoopCross => "quests",
EffectRootKind::DialogueRespawn => "dialogue",
}
}
pub fn runs_with_acting_player(self) -> bool {
match self {
EffectRootKind::ObjectiveComplete
| EffectRootKind::QuestComplete
| EffectRootKind::DialogueRespawn
| EffectRootKind::OnDeath
| EffectRootKind::ShopOffer
| EffectRootKind::OnKill => true,
EffectRootKind::Trigger
| EffectRootKind::TrapPayload
| EffectRootKind::ShortcutUnlock
| EffectRootKind::AssemblyLand
| EffectRootKind::LoopCross => false,
}
}
pub fn label(self) -> &'static str {
match self {
EffectRootKind::ObjectiveComplete => "quest on_objective_complete",
EffectRootKind::QuestComplete => "quest on_complete",
EffectRootKind::Trigger => "trigger effects",
EffectRootKind::TrapPayload => "trap payload",
EffectRootKind::DialogueRespawn => "dialogue set-checkpoint on_respawn",
EffectRootKind::ShortcutUnlock => "shortcut on_unlock",
EffectRootKind::OnDeath => "campaign on_death",
EffectRootKind::ShopOffer => "shop offer effects",
EffectRootKind::OnKill => "fight on_kill",
EffectRootKind::AssemblyLand => "assembly strike on_land",
EffectRootKind::LoopCross => "loop on_cross",
}
}
}
#[derive(Clone, Copy)]
pub enum EffectRootOwner<'a> {
ObjectiveComplete {
quest: &'a Quest,
objective: &'a str,
},
QuestComplete {
quest: &'a Quest,
},
Trigger(&'a EnvTrigger),
TrapPayload(&'a Trap),
DialogueRespawn,
ShortcutUnlock(&'a Shortcut),
OnDeath,
ShopOffer(&'a Shop),
OnKill(Fight<'a>),
AssemblyLand(&'a Assembly),
LoopCross(&'a Loop),
}
impl<'a> EffectRootOwner<'a> {
pub fn kind(&self) -> EffectRootKind {
match self {
EffectRootOwner::ObjectiveComplete { .. } => EffectRootKind::ObjectiveComplete,
EffectRootOwner::QuestComplete { .. } => EffectRootKind::QuestComplete,
EffectRootOwner::Trigger(_) => EffectRootKind::Trigger,
EffectRootOwner::TrapPayload(_) => EffectRootKind::TrapPayload,
EffectRootOwner::DialogueRespawn => EffectRootKind::DialogueRespawn,
EffectRootOwner::ShortcutUnlock(_) => EffectRootKind::ShortcutUnlock,
EffectRootOwner::OnDeath => EffectRootKind::OnDeath,
EffectRootOwner::ShopOffer(_) => EffectRootKind::ShopOffer,
EffectRootOwner::OnKill(_) => EffectRootKind::OnKill,
EffectRootOwner::AssemblyLand(_) => EffectRootKind::AssemblyLand,
EffectRootOwner::LoopCross(_) => EffectRootKind::LoopCross,
}
}
pub fn runs_with_acting_player(&self) -> bool {
match self {
EffectRootOwner::Trigger(t) => t.addresses_presser(),
other => other.kind().runs_with_acting_player(),
}
}
pub fn quest(&self) -> Option<&'a Quest> {
match self {
EffectRootOwner::ObjectiveComplete { quest, .. }
| EffectRootOwner::QuestComplete { quest } => Some(quest),
EffectRootOwner::Trigger(_)
| EffectRootOwner::TrapPayload(_)
| EffectRootOwner::DialogueRespawn
| EffectRootOwner::ShortcutUnlock(_)
| EffectRootOwner::OnDeath
| EffectRootOwner::ShopOffer(_)
| EffectRootOwner::OnKill(_)
| EffectRootOwner::AssemblyLand(_)
| EffectRootOwner::LoopCross(_) => None,
}
}
}
pub struct EffectRootSite<'a> {
pub owner: EffectRootOwner<'a>,
pub stage: &'static str,
pub path: String,
pub key: String,
}
impl EffectRootSite<'_> {
pub fn kind(&self) -> EffectRootKind {
self.owner.kind()
}
pub fn runs_with_acting_player(&self) -> bool {
self.owner.runs_with_acting_player()
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RootBinding {
pub roots_enumerated: usize,
pub sites: [(EffectRootKind, usize); EffectRootKind::COUNT],
pub effects: usize,
}
impl RootBinding {
pub fn unbound_roots(&self) -> Vec<EffectRootKind> {
self.sites
.iter()
.filter(|(_, n)| *n == 0)
.map(|(k, _)| *k)
.collect()
}
pub fn to_json(&self) -> serde_json::Value {
let mut sites = serde_json::Map::new();
for (kind, n) in &self.sites {
sites.insert(kind.label().to_string(), serde_json::json!(n));
}
serde_json::json!({
"roots_enumerated": self.roots_enumerated,
"roots_total": EffectRootKind::COUNT,
"bundles": self.sites.iter().map(|(_, n)| n).sum::<usize>(),
"effects": self.effects,
"sites": serde_json::Value::Object(sites),
"unbound_roots": self
.unbound_roots()
.iter()
.map(|k| k.label())
.collect::<Vec<_>>(),
})
}
pub fn summary(&self) -> String {
let per: Vec<String> = self
.sites
.iter()
.map(|(k, n)| format!("{}={n}", k.label()))
.collect();
format!(
"roots {}/{}, bundles {}, effects {} [{}]",
self.roots_enumerated,
EffectRootKind::COUNT,
self.sites.iter().map(|(_, n)| n).sum::<usize>(),
self.effects,
per.join(", ")
)
}
}
macro_rules! effect_root_walk {
(
campaign: $c:expr,
iter: $iter:ident,
slice: $slice:ident,
respawn: $respawn:ident,
note: $note:expr,
visit: $visit:expr,
quest_owner: |$q:ident| $ownq:expr,
trigger_owner: |$t:ident| $ownt:expr,
trap_owner: |$p:ident| $ownp:expr,
dialogue_owner: $ownd:expr,
shortcut_owner: |$s:ident| $owns:expr,
death_owner: $ownx:expr,
shop_owner: |$h:ident| $ownh:expr,
opt: $opt:ident,
wave_owner: |$w:ident| $ownw:expr,
actor_owner: |$a:ident| $owna:expr,
assembly_owner: |$m:ident| $ownm:expr,
loop_owner: |$l:ident| $ownl:expr,
) => {{
#[allow(unused_mut)]
let mut visit = $visit;
#[allow(unused_mut)]
let mut note = $note;
note(EffectRootKind::ObjectiveComplete);
note(EffectRootKind::QuestComplete);
for (qi, $q) in $c.quests.content.quests.$iter().enumerate() {
let ql = local($q.id.as_str()).to_string();
let owner = $ownq;
for (oid, effs) in $q.on_objective_complete.$iter() {
let ol = local(oid.as_str()).to_string();
visit(
(EffectRootKind::ObjectiveComplete, owner, Some(oid.as_str())),
format!(
"/content/quests/{qi}/on_objective_complete/{}",
oid.as_str()
),
format!("fx.{ql}.oc.{ol}"),
effs.$slice(),
);
}
visit(
(EffectRootKind::QuestComplete, owner, None),
format!("/content/quests/{qi}/on_complete"),
format!("fx.{ql}.done"),
$q.on_complete.$slice(),
);
}
note(EffectRootKind::Trigger);
for (ti, $t) in $c.quests.content.triggers.$iter().enumerate() {
let tl = local($t.id.as_str()).to_string();
let owner = $ownt;
visit(
(EffectRootKind::Trigger, owner, None),
format!("/content/triggers/{ti}/effects"),
format!("fx.trig.{tl}"),
$t.effects.$slice(),
);
}
note(EffectRootKind::TrapPayload);
for (pi, $p) in $c.quests.content.traps.$iter().enumerate() {
let pl = local($p.id.as_str()).to_string();
let owner = $ownp;
visit(
(EffectRootKind::TrapPayload, owner, None),
format!("/content/traps/{pi}/payload"),
format!("fx.trap.{pl}"),
$p.payload.$slice(),
);
}
note(EffectRootKind::DialogueRespawn);
for (di, tree) in $c.dialogue.content.dialogues.$iter().enumerate() {
let np = local(tree.npc.as_str()).to_string();
let owner = $ownd;
for (ni, node) in tree.nodes.$iter().enumerate() {
let nd = local(node.id.as_str()).to_string();
for (oi, opt) in node.options.$iter().enumerate() {
for (ei, de) in opt.effects.$iter().enumerate() {
let Some(on_respawn) = de.$respawn() else {
continue;
};
visit(
(EffectRootKind::DialogueRespawn, owner, None),
format!(
"/content/dialogues/{di}/nodes/{ni}/options/{oi}/effects/{ei}/on_respawn"
),
format!("fx.dlg.{np}.{nd}.{oi}.{ei}.respawn"),
on_respawn,
);
}
}
}
}
note(EffectRootKind::ShortcutUnlock);
for (si, $s) in $c.quests.content.shortcuts.$iter().enumerate() {
let sl = local($s.id.as_str()).to_string();
let owner = $owns;
visit(
(EffectRootKind::ShortcutUnlock, owner, None),
format!("/content/shortcuts/{si}/on_unlock"),
format!("fx.sc.{sl}"),
$s.on_unlock.$slice(),
);
}
note(EffectRootKind::OnDeath);
{
let owner = $ownx;
let on_death = $c.quests.content.on_death.$slice();
if !on_death.is_empty() {
visit(
(EffectRootKind::OnDeath, owner, None),
"/content/on_death".to_string(),
"fx.death".to_string(),
on_death,
);
}
}
note(EffectRootKind::ShopOffer);
for (hi, $h) in $c.quests.content.shops.$iter().enumerate() {
let hl = local($h.id.as_str()).to_string();
let owner = $ownh;
for (oi, off) in $h.offers.$iter().enumerate() {
visit(
(EffectRootKind::ShopOffer, owner, None),
format!("/content/shops/{hi}/offers/{oi}/effects"),
format!("fx.shop.{hl}.{oi}"),
off.effects.$slice(),
);
}
}
note(EffectRootKind::OnKill);
for (wi, $w) in $c.quests.content.waves.$iter().enumerate() {
let wl = local($w.id.as_str()).to_string();
let owner = $ownw;
if let Some(ok) = $w.on_kill.$opt() {
visit(
(EffectRootKind::OnKill, owner, None),
format!("/content/waves/{wi}/on_kill/effects"),
format!("wave.{wl}.on_kill"),
ok.effects.$slice(),
);
}
}
for (ai, $a) in $c.quests.content.actors.$iter().enumerate() {
let al = local($a.id.as_str()).to_string();
let owner = $owna;
if let Some(ok) = $a.on_kill.$opt() {
visit(
(EffectRootKind::OnKill, owner, None),
format!("/content/actors/{ai}/on_kill/effects"),
format!("actor.{al}.on_kill"),
ok.effects.$slice(),
);
}
}
note(EffectRootKind::AssemblyLand);
for (mi, $m) in $c.quests.content.assemblies.$iter().enumerate() {
let ml = local($m.id.as_str()).to_string();
let owner = $ownm;
if let Some(strikes) = $m.strikes.$opt() {
for (si, step) in strikes.pattern.$iter().enumerate() {
if step.on_land.is_empty() {
continue;
}
visit(
(EffectRootKind::AssemblyLand, owner, None),
format!("/content/assemblies/{mi}/strikes/pattern/{si}/on_land"),
format!("assembly.{ml}.strike.{si}"),
step.on_land.$slice(),
);
}
}
}
note(EffectRootKind::LoopCross);
for (li, $l) in $c.quests.content.loops.$iter().enumerate() {
let ll = local($l.id.as_str()).to_string();
let owner = $ownl;
if !$l.on_cross.is_empty() {
visit(
(EffectRootKind::LoopCross, owner, None),
format!("/content/loops/{li}/on_cross"),
format!("fx.loop.{ll}"),
$l.on_cross.$slice(),
);
}
}
}};
}
#[derive(Clone, Copy)]
enum RawOwner<'a> {
Quest(&'a Quest),
Trigger(&'a EnvTrigger),
Trap(&'a Trap),
Dialogue,
Shortcut(&'a Shortcut),
Death,
Shop(&'a Shop),
Fight(Fight<'a>),
Assembly(&'a Assembly),
Loop(&'a Loop),
}
impl<'a> RawOwner<'a> {
fn attach(self, kind: EffectRootKind, objective: Option<&'a str>) -> EffectRootOwner<'a> {
match (self, kind) {
(RawOwner::Quest(quest), EffectRootKind::ObjectiveComplete) => {
EffectRootOwner::ObjectiveComplete {
quest,
objective: objective
.expect("an on_objective_complete root always names its objective"),
}
}
(RawOwner::Quest(quest), EffectRootKind::QuestComplete) => {
EffectRootOwner::QuestComplete { quest }
}
(RawOwner::Trigger(t), EffectRootKind::Trigger) => EffectRootOwner::Trigger(t),
(RawOwner::Trap(p), EffectRootKind::TrapPayload) => EffectRootOwner::TrapPayload(p),
(RawOwner::Dialogue, EffectRootKind::DialogueRespawn) => {
EffectRootOwner::DialogueRespawn
}
(RawOwner::Shortcut(s), EffectRootKind::ShortcutUnlock) => {
EffectRootOwner::ShortcutUnlock(s)
}
(RawOwner::Death, EffectRootKind::OnDeath) => EffectRootOwner::OnDeath,
(RawOwner::Shop(h), EffectRootKind::ShopOffer) => EffectRootOwner::ShopOffer(h),
(RawOwner::Fight(f), EffectRootKind::OnKill) => EffectRootOwner::OnKill(f),
(RawOwner::Assembly(m), EffectRootKind::AssemblyLand) => {
EffectRootOwner::AssemblyLand(m)
}
(RawOwner::Loop(l), EffectRootKind::LoopCross) => EffectRootOwner::LoopCross(l),
(owner, kind) => unreachable!(
"effect root {kind:?} was handed an owner of the wrong shape ({})",
match owner {
RawOwner::Quest(_) => "quest",
RawOwner::Trigger(_) => "trigger",
RawOwner::Trap(_) => "trap",
RawOwner::Dialogue => "dialogue",
RawOwner::Shortcut(_) => "shortcut",
RawOwner::Death => "on_death",
RawOwner::Shop(_) => "shop",
RawOwner::Fight(_) => "fight",
RawOwner::Assembly(_) => "assembly",
RawOwner::Loop(_) => "loop",
}
),
}
}
}
pub fn for_each_effect_root<'a>(
c: &'a Campaign,
f: &mut dyn FnMut(&EffectRootSite<'a>, &'a [QuestEffect]),
) -> RootBinding {
let mut sites = [
(EffectRootKind::ObjectiveComplete, 0usize),
(EffectRootKind::QuestComplete, 0usize),
(EffectRootKind::Trigger, 0usize),
(EffectRootKind::TrapPayload, 0usize),
(EffectRootKind::DialogueRespawn, 0usize),
(EffectRootKind::ShortcutUnlock, 0usize),
(EffectRootKind::OnDeath, 0usize),
(EffectRootKind::ShopOffer, 0usize),
(EffectRootKind::OnKill, 0usize),
(EffectRootKind::AssemblyLand, 0usize),
(EffectRootKind::LoopCross, 0usize),
];
debug_assert_eq!(
sites.map(|(k, _)| k),
EffectRootKind::ALL,
"the binding ledger's slots are EffectRootKind::ALL, in order"
);
let mut effects = 0usize;
let mut enumerated = [false; EffectRootKind::COUNT];
fn slot_of(kind: EffectRootKind) -> usize {
EffectRootKind::ALL
.iter()
.position(|k| *k == kind)
.expect("every root kind is a member of EffectRootKind::ALL")
}
effect_root_walk!(
campaign: c,
iter: iter,
slice: as_slice,
respawn: set_checkpoint_on_respawn,
note: |kind: EffectRootKind| {
enumerated[slot_of(kind)] = true;
},
visit: |(kind, owner, objective): (EffectRootKind, RawOwner<'a>, Option<&'a str>),
path: String,
key: String,
list: &'a [QuestEffect]| {
let owner = owner.attach(kind, objective);
debug_assert_eq!(owner.kind(), kind, "a site's owner and kind must agree");
let slot = slot_of(kind);
sites[slot].1 += 1;
effects += list.len();
f(
&EffectRootSite {
owner,
stage: kind.stage(),
path,
key,
},
list,
);
},
quest_owner: |q| RawOwner::Quest(q),
trigger_owner: |t| RawOwner::Trigger(t),
trap_owner: |p| RawOwner::Trap(p),
dialogue_owner: RawOwner::Dialogue,
shortcut_owner: |s| RawOwner::Shortcut(s),
death_owner: RawOwner::Death,
shop_owner: |h| RawOwner::Shop(h),
opt: as_ref,
wave_owner: |w| RawOwner::Fight(Fight::Wave(w)),
actor_owner: |a| RawOwner::Fight(Fight::Actor(a)),
assembly_owner: |m| RawOwner::Assembly(m),
loop_owner: |l| RawOwner::Loop(l),
);
let missed: Vec<&str> = EffectRootKind::ALL
.iter()
.zip(enumerated)
.filter(|(_, s)| !*s)
.map(|(k, _)| k.label())
.collect();
assert!(
missed.is_empty(),
"for_each_effect_root enumerated {} of {} effect roots — missing: {}. A root that \
stops being enumerated has no other symptom.",
EffectRootKind::COUNT - missed.len(),
EffectRootKind::COUNT,
missed.join(", ")
);
RootBinding {
roots_enumerated: EffectRootKind::COUNT,
sites,
effects,
}
}
pub type RootVisitorMut<'a, 'f> = dyn FnMut(EffectRootKind, &str, &str, &'a mut [QuestEffect]) + 'f;
pub fn for_each_effect_root_mut<'a>(c: &'a mut Campaign, f: &mut RootVisitorMut<'a, '_>) {
effect_root_walk!(
campaign: c,
iter: iter_mut,
slice: as_mut_slice,
respawn: set_checkpoint_on_respawn_mut,
note: |_kind: EffectRootKind| {},
visit: |(kind, _owner, _objective): (EffectRootKind, (), Option<&str>),
path: String,
key: String,
list: &'a mut [QuestEffect]| {
f(kind, &path, &key, list);
},
quest_owner: |_q| (),
trigger_owner: |_t| (),
trap_owner: |_p| (),
dialogue_owner: (),
shortcut_owner: |_s| (),
death_owner: (),
shop_owner: |_h| (),
opt: as_mut,
wave_owner: |_w| (),
actor_owner: |_a| (),
assembly_owner: |_m| (),
loop_owner: |_l| (),
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binding_slots_are_all_the_roots_in_order() {
let b = RootBinding {
roots_enumerated: EffectRootKind::COUNT,
sites: [
(EffectRootKind::ObjectiveComplete, 0),
(EffectRootKind::QuestComplete, 0),
(EffectRootKind::Trigger, 0),
(EffectRootKind::TrapPayload, 0),
(EffectRootKind::DialogueRespawn, 0),
(EffectRootKind::ShortcutUnlock, 0),
(EffectRootKind::OnDeath, 0),
(EffectRootKind::ShopOffer, 0),
(EffectRootKind::OnKill, 0),
(EffectRootKind::AssemblyLand, 0),
(EffectRootKind::LoopCross, 0),
],
effects: 0,
};
assert_eq!(b.sites.map(|(k, _)| k), EffectRootKind::ALL);
assert_eq!(b.unbound_roots().len(), EffectRootKind::COUNT);
}
#[test]
fn every_root_names_its_stage() {
for k in EffectRootKind::ALL {
assert!(matches!(k.stage(), "quests" | "dialogue"), "{k:?}");
assert!(!k.label().is_empty(), "{k:?}");
}
}
}