Skip to main content

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}