delvewright_dsl/fight.rs
1//! **The two classes a fight takes** — a wave (`dw_wave_<id>`) and an actor
2//! (`dw_actor_<id>`) — and the static facts about them that more than one rule
3//! reads.
4//!
5//! A property that acts on *a body being killed* belongs to both classes, the
6//! pair that already carries `tier` and `equipment` as one shape. [`Fight`] is
7//! that pair as one value, so a consumer that reasons about "a fight" matches on
8//! it once rather than writing a wave arm and an actor arm that can drift.
9//!
10//! The facts here are read by the document-tier rules (`DW0913`) and by the
11//! compiler (`plan::wave_area`, `combat::hostile_actors`), and each is stated
12//! once:
13//!
14//! * [`wave_area`] — the area a wave's bodies are seated in, resolved from the
15//! beat that spawns it. `None` is the fact that leaves a wave without any
16//! runtime machinery.
17//! * [`unleashed_actors`] — every actor some `unleash-actor` names, at any effect
18//! root and any nesting: the definition of an actor that is a fight.
19//!
20//! Determinism (ADR-0006): every walk is over slices and `BTreeSet`s.
21
22use std::collections::BTreeSet;
23
24use crate::envelope::Campaign;
25use crate::healthbar::HealthBar;
26use crate::{
27 Actor, EncounterTier, Objective, OnKill, QuestEffect, Verb, Wave, for_each_campaign_effect,
28};
29
30/// Which of the two fight classes a [`Fight`] is — the value a consumer that
31/// only needs the class (a diagnostic's word, a body tag's prefix) compares.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
33pub enum FightKind {
34 /// A `waves[]` entry — bodies tagged `dw_wave_<id>`.
35 Wave,
36 /// An `actors[]` entry — bodies tagged `dw_actor_<id>`.
37 Actor,
38}
39
40impl FightKind {
41 /// The kebab word, as a reader of a diagnostic knows the class.
42 pub fn word(self) -> &'static str {
43 match self {
44 FightKind::Wave => "wave",
45 FightKind::Actor => "actor",
46 }
47 }
48}
49
50/// One fight the campaign declares: a wave or an actor. The one type every rule
51/// over "a fight" reads — the `health_bar` (spec-0073) and the `on_kill`
52/// (spec-0074) alike.
53#[derive(Clone, Copy, Debug)]
54pub enum Fight<'a> {
55 /// A `waves[]` entry — its bodies wear `dw_wave_<id>`.
56 Wave(&'a Wave),
57 /// An `actors[]` entry — its body wears `dw_actor_<id>`.
58 Actor(&'a Actor),
59}
60
61impl<'a> Fight<'a> {
62 /// Which class this fight is.
63 pub fn kind(&self) -> FightKind {
64 match self {
65 Fight::Wave(_) => FightKind::Wave,
66 Fight::Actor(_) => FightKind::Actor,
67 }
68 }
69
70 /// The kebab word a reader of a diagnostic knows the class by.
71 pub fn word(&self) -> &'static str {
72 self.kind().word()
73 }
74
75 /// The billing the fight declares.
76 pub fn tier(&self) -> Option<EncounterTier> {
77 match self {
78 Fight::Wave(w) => w.tier,
79 Fight::Actor(a) => a.tier,
80 }
81 }
82
83 /// The fight's `health_bar`, if it declares one (spec-0073).
84 pub fn health_bar(&self) -> Option<&'a HealthBar> {
85 match self {
86 Fight::Wave(w) => w.health_bar.as_ref(),
87 Fight::Actor(a) => a.health_bar.as_ref(),
88 }
89 }
90
91 /// The name the fight carries of its own: an actor's `name`, or the `name`
92 /// of a wave's one mob entry. `None` for a wave of two entries or more, and
93 /// for a body with no name.
94 pub fn own_name(&self) -> Option<&'a str> {
95 match self {
96 Fight::Wave(w) => match w.mobs.as_slice() {
97 [only] => only.name.as_deref(),
98 _ => None,
99 },
100 Fight::Actor(a) => a.name.as_deref(),
101 }
102 }
103
104 /// How many mob entries a wave declares (1 for an actor).
105 pub fn entries(&self) -> usize {
106 match self {
107 Fight::Wave(w) => w.mobs.len(),
108 Fight::Actor(_) => 1,
109 }
110 }
111
112 /// Whether the fight's standing body can be hurt before any `unleash`: an
113 /// actor's `vulnerable`; always for a wave, whose bodies are never
114 /// invulnerable.
115 pub fn vulnerable(&self) -> bool {
116 match self {
117 Fight::Wave(_) => true,
118 Fight::Actor(a) => a.vulnerable,
119 }
120 }
121
122 /// The declared id (`wave/<kebab>` or `actor/<kebab>`).
123 pub fn id(&self) -> &'a str {
124 match self {
125 Fight::Wave(w) => w.id.as_str(),
126 Fight::Actor(a) => a.id.as_str(),
127 }
128 }
129
130 /// The fight's `on_kill` bundle, if it declares one (spec-0074).
131 pub fn on_kill(&self) -> Option<&'a OnKill> {
132 match self {
133 Fight::Wave(w) => w.on_kill.as_ref(),
134 Fight::Actor(a) => a.on_kill.as_ref(),
135 }
136 }
137}
138
139/// Every fight the campaign declares, waves first then actors, each in
140/// declaration order, with the JSON pointer to its declaration in the quests
141/// document.
142pub fn fights(c: &Campaign) -> Vec<(String, Fight<'_>)> {
143 let content = &c.quests.content;
144 let mut out = Vec::new();
145 for (i, w) in content.waves.iter().enumerate() {
146 out.push((format!("/content/waves/{i}"), Fight::Wave(w)));
147 }
148 for (i, a) in content.actors.iter().enumerate() {
149 out.push((format!("/content/actors/{i}"), Fight::Actor(a)));
150 }
151 out
152}
153
154/// Every actor some `unleash-actor` names, at any effect root and any nesting —
155/// the one definition of an actor that is a *fight*. `combat::hostile_actors`
156/// is this set over the declared actors.
157pub fn unleashed_actors(c: &Campaign) -> BTreeSet<&str> {
158 let mut out = BTreeSet::new();
159 for_each_campaign_effect(c, &mut |_, _, eff| {
160 if let Verb::UnleashActor { actor, .. } = &eff.verb {
161 out.insert(actor.as_str());
162 }
163 });
164 out
165}
166
167/// The area a stage-4 quest belongs to.
168pub fn quest_area<'a>(c: &'a Campaign, quest_id: &str) -> Option<&'a str> {
169 c.quest_plan
170 .content
171 .quests
172 .iter()
173 .find(|q| q.id.as_str() == quest_id)
174 .map(|q| q.area.as_str())
175}
176
177/// Does any effect in `effs`, or anywhere in the trees nested under them, fire a
178/// `spawn-wave` for `wave_id`?
179///
180/// Descends through [`QuestEffect::visit_deep`], so `sequence` steps,
181/// `set-checkpoint` `on_respawn`, `bonfire` `on_rest`, `begin-stealth`
182/// `on_caught` and `move-npc`/`move-actor` `on_arrive` are all spawn sites — as
183/// they already are for emission. A verb the emitter compiles from a nesting site
184/// is a verb every consumer scan must see from the same site.
185fn fires_wave<'a>(effs: impl IntoIterator<Item = &'a QuestEffect>, wave_id: &str) -> bool {
186 let mut found = false;
187 for e in effs {
188 e.visit_deep(&mut |x| {
189 if matches!(x.spawn_wave(), Some(w) if w.as_str() == wave_id) {
190 found = true;
191 }
192 });
193 }
194 found
195}
196
197/// The area a wave's mobs spawn in — resolved from the wave's **spawn site**, not
198/// from any `kill` objective. A `spawn-wave` effect (on a quest step, on a quest's
199/// completion, on an environment trigger, or in another fight's `on_kill`) is
200/// what makes a wave appear; its mobs materialize at `Wave.anchor` resolved in
201/// that spawning site's area. This is deliberately independent of objective
202/// type so a kill-less "live threat" wave (spec-0008 §4 — e.g. a weakened warden
203/// the player sneaks past, an ambient mob flock) resolves a spawn position
204/// exactly like a wave that is later slain.
205///
206/// Resolution order: the quest that fires the `spawn-wave` (`on_objective_complete`
207/// or `on_complete`); else, in a single-area campaign, an environment trigger or a
208/// trap payload that fires it (both are global — their sole possible area is the
209/// one area); else a fight's `on_kill` that fires it (spec-0074) — the area of the
210/// wave whose kill fires it, or, for an actor's kill, the sole area of a
211/// single-area campaign exactly as for a trigger; else a quest whose `kill`
212/// objective references the wave (defensive fallback for a wave declared with a
213/// kill but no explicit spawn). `None` if nothing spawns it.
214///
215/// "Single-area" is the placement authority's answer ([`crate::placement::Placement`],
216/// spec-0093 §6.1): a site-plan campaign declares no `areas[]` and has exactly
217/// one area, [`crate::siteplan::SITE_AREA`]. Counting `areas[]` alone called the
218/// one kind of campaign that is always single-area multi-area, and refused every
219/// trigger-fired wave on a site plan as unplaceable.
220///
221/// **Every root is walked DEEP** (`fires_wave`), through
222/// [`QuestEffect::nested_effect_lists`] — the DSL's single authority on effect
223/// nesting, and the same authority `emit::all_campaign_effects` walks to decide
224/// what to compile. A wave the emitter writes a `function <ns>:spawn_<wave>` call
225/// for is therefore always a wave this function resolves an area for, and so
226/// always a wave whose support machinery is emitted: the agreement is structural,
227/// not two walks that have to remember each other.
228///
229/// It used to be a shallow scan of the top-level chains only, and the island's
230/// round-21 build is what that cost: `wave/storm-shore` and `wave/storm-fire` were
231/// fired from step 7 of a `sequence`, resolved no area, got no `spawn_…`, no
232/// census, no brand and no kill reward — while `seq_under_ram` still shipped the
233/// call. Two of three storm waves never spawned (`DW0497` is now the standing
234/// proof that this class cannot ship again).
235pub fn wave_area<'a>(campaign: &'a Campaign, wave_id: &str) -> Option<&'a str> {
236 wave_area_seen(campaign, wave_id, &mut BTreeSet::new())
237}
238
239fn wave_area_seen<'a>(
240 campaign: &'a Campaign,
241 wave_id: &str,
242 seen: &mut BTreeSet<String>,
243) -> Option<&'a str> {
244 if !seen.insert(wave_id.to_string()) {
245 // A wave whose only spawn site is its own kill (directly or through a
246 // ring of fights) is seated by nothing.
247 return None;
248 }
249 let content = &campaign.quests.content;
250 // 1. A quest whose effect TREE fires `spawn-wave` for this wave — the true
251 // spawn site.
252 for q in &content.quests {
253 if fires_wave(
254 q.on_objective_complete
255 .values()
256 .flatten()
257 .chain(&q.on_complete),
258 wave_id,
259 ) {
260 return quest_area(campaign, q.id.as_str());
261 }
262 }
263 // Which campaigns are single-area is the placement authority's to say
264 // (spec-0093 §6.1): a site plan has exactly one area, `SITE_AREA`, and an
265 // empty `areas[]`; a prefab campaign is single-area when it declares one.
266 let (single_area, sole_area): (bool, Option<&str>) =
267 match crate::placement::Placement::of(campaign) {
268 crate::placement::Placement::SitePlan => (true, Some(crate::siteplan::SITE_AREA)),
269 crate::placement::Placement::Prefabs => (
270 campaign.world.content.areas.len() == 1,
271 campaign.world.content.areas.first().map(|a| a.id.as_str()),
272 ),
273 crate::placement::Placement::NoMap => (false, None),
274 };
275 let sole_area = || sole_area;
276 // 2. An environment trigger or trap payload that fires it. Both are global
277 // effect roots carrying no area of their own; in a single-area campaign the
278 // sole area is unambiguous. (Multi-area trigger-only waves are not
279 // resolvable here and surface as a build diagnostic rather than a silent
280 // dangling spawn.)
281 if single_area
282 && (content
283 .triggers
284 .iter()
285 .any(|t| fires_wave(&t.effects, wave_id))
286 || content
287 .traps
288 .iter()
289 .any(|t| fires_wave(&t.payload, wave_id)))
290 {
291 return sole_area();
292 }
293 // 3. A fight's `on_kill` that fires it (spec-0074). A wave's kill plays where
294 // that wave's bodies stand; an actor's kill is global like a trigger.
295 for w in &content.waves {
296 if let Some(ok) = &w.on_kill
297 && fires_wave(&ok.effects, wave_id)
298 && let Some(area) = wave_area_seen(campaign, w.id.as_str(), seen)
299 {
300 return Some(area);
301 }
302 }
303 if single_area
304 && content.actors.iter().any(|a| {
305 a.on_kill
306 .as_ref()
307 .is_some_and(|ok| fires_wave(&ok.effects, wave_id))
308 })
309 {
310 return sole_area();
311 }
312 // 4. Defensive fallback: a `kill` objective's quest.
313 for q in &content.quests {
314 if q.objectives
315 .iter()
316 .any(|o| matches!(o, Objective::Kill { wave, .. } if wave.as_str() == wave_id))
317 {
318 return quest_area(campaign, q.id.as_str());
319 }
320 }
321 None
322}