Skip to main content

delvewright_dsl/
healthbar.rs

1//! **A fight shows its health** (spec-0073): the `health_bar` a wave or an
2//! actor may declare, and the document-tier rules that judge it.
3//!
4//! A bar acts on a *fight*, and the engine has exactly two fight classes — the
5//! wave (`dw_wave_<id>`) and the actor (`dw_actor_<id>`) — the two that carry
6//! [`EncounterTier`] and the two the undefeated re-seat is defined over. One
7//! type serves both, on the `equipment` precedent: one shape on two object
8//! classes, so the two surfaces cannot drift.
9//!
10//! The bar is **declared, never derived from `tier`**: `tier` is a declaration
11//! the compiler may not scale content from, and drawing a bar because a fight is
12//! billed `boss` would make the billing a knob. A `boss`-billed fight with no bar
13//! is legal and builds; it is advised ([`codes::HEALTH_BAR_ADVISED`], warning
14//! tier), and no other tier ever is.
15//!
16//! What lives here, and what does not:
17//!
18//! * [`health_bar_checks`] — the rules that need only the documents: the
19//!   schema's `range` bound restated at the document tier (`DW0100`), a bar over
20//!   a body whose health cannot move (`DW0909`), a bar with nothing to title it
21//!   (`DW0910`), and the advisory (`DW0912`).
22//! * The colour and style vocabulary is **not** here. It is whatever the pinned
23//!   command tree lists under `bossbar set <id> color|style`, and that tree is
24//!   `delvec`'s data, so the rule that reads it (`DW0911`) is compiler-side —
25//!   the one authority the emitter already holds every line to, never a second
26//!   copy of it in this crate.
27//! * [`HealthBarBinding`] — what the rules examined on a campaign, with the
28//!   denominator, so a campaign that declares no bar reads as zero bound rather
29//!   than as a pass.
30
31use schemars::JsonSchema;
32use serde::{Deserialize, Serialize};
33
34use crate::EncounterTier;
35use crate::diagnostic::{Diagnostic, codes};
36use crate::envelope::Campaign;
37use crate::fight::{Fight, FightKind, fights, unleashed_actors};
38
39/// The smallest `range` a bar may declare, in blocks — the same bound
40/// `lane.aggro_radius` carries.
41pub const MIN_RANGE: u32 = 4;
42/// The largest `range` a bar may declare, in blocks.
43pub const MAX_RANGE: u32 = 64;
44
45/// A health bar over one fight (spec-0073 §2): a named bar over the fight's
46/// total health, drawn for every player within `range` blocks of a live body of
47/// the fight, from the moment they enter that range until the last body falls.
48#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct HealthBar {
51    /// The bar's player-visible title. Absent = the fight's own name: an actor's
52    /// `name`, or the `name` of a wave's one mob entry. A wave of two entries or
53    /// more, or a body with no name, must state it (`DW0910`).
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub title: Option<String>,
56    /// How near a live body of the fight a player must stand to see the bar, in
57    /// blocks (4..=64): the creator's statement of the arena. Required — the bar
58    /// is meant to be seen on crossing the threshold, before the body has
59    /// perceived anyone, so it is not the body's `follow_range`.
60    #[schemars(range(min = 4, max = 64))]
61    pub range: u32,
62    /// The bar's colour: one of the literals the pinned game's
63    /// `bossbar set <id> color` accepts (`DW0911`). Absent = the game's default.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub color: Option<String>,
66    /// The bar's style: one of the literals the pinned game's
67    /// `bossbar set <id> style` accepts (`DW0911`). Absent = the game's default.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub style: Option<String>,
70}
71
72impl<'a> Fight<'a> {
73    /// The title the fight's bar draws: a stated `title` always wins, otherwise
74    /// the fight's own name (spec-0073 §5). `None` when the fight declares no
75    /// bar, or when there is nothing to draw, which is `DW0910`.
76    pub fn bar_title(&self) -> Option<&'a str> {
77        let bar = self.health_bar()?;
78        match bar.title.as_deref() {
79            Some(t) => Some(t),
80            None => self.own_name(),
81        }
82    }
83}
84
85/// The document-tier health-bar rules (spec-0073 §8): `DW0100` for a `range`
86/// outside the schema's bound, `DW0909`, `DW0910`, and the `DW0912` advisory.
87/// Every walk is over [`fights`]; a campaign that declares no bar and bills no
88/// fight `boss` gets nothing.
89pub fn health_bar_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
90    let unleashed = unleashed_actors(c);
91    for (path, f) in fights(c) {
92        let Some(bar) = f.health_bar() else {
93            if f.tier() == Some(EncounterTier::Boss) {
94                d.push(Diagnostic::warning(
95                    codes::HEALTH_BAR_ADVISED,
96                    "quests",
97                    format!("{path}/tier"),
98                    format!(
99                        "{kind} `{id}` is billed `tier: boss` and declares no `health_bar`, so a \
100                         player fighting it sees nothing of how the fight is going. This is \
101                         advice, not a refusal: the build goes on. To show it, declare \
102                         `health_bar: {{ range: <blocks> }}` on the {kind} — a bar titled with \
103                         its name, over its bodies' total health, drawn for every player within \
104                         `range` blocks of it.",
105                        kind = f.word(),
106                        id = f.id(),
107                    ),
108                ));
109            }
110            continue;
111        };
112        let bar_path = format!("{path}/health_bar");
113        if !(MIN_RANGE..=MAX_RANGE).contains(&bar.range) {
114            d.push(Diagnostic::error(
115                codes::SCHEMA,
116                "quests",
117                format!("{bar_path}/range"),
118                format!(
119                    "`health_bar` `range` {} on {} `{}` is outside {MIN_RANGE}..={MAX_RANGE}. It \
120                     is the distance, in blocks, at which a player standing near a live body of \
121                     the fight sees its bar: below {MIN_RANGE} the bar appears only once the \
122                     player is already in contact, and past {MAX_RANGE} it is drawn across \
123                     rooms that are not the fight's. State the arena's size in \
124                     {MIN_RANGE}..={MAX_RANGE}.",
125                    bar.range,
126                    f.word(),
127                    f.id()
128                ),
129            ));
130        }
131        match bar.title.as_deref() {
132            Some(t) if t.trim().is_empty() => d.push(Diagnostic::error(
133                codes::HEALTH_BAR_UNTITLED,
134                "quests",
135                format!("{bar_path}/title"),
136                format!(
137                    "`health_bar` `title` on {} `{}` is blank, so the bar would be drawn with \
138                     nothing on it. Write the title the player reads over the bar, or remove \
139                     `title` to draw the fight's own name.",
140                    f.word(),
141                    f.id()
142                ),
143            )),
144            Some(_) => {}
145            None if f.own_name().is_none() => {
146                let why = match f.kind() {
147                    FightKind::Wave if f.entries() != 1 => format!(
148                        "the wave declares {} mob entries, and no one entry's name is the \
149                         name of the whole fight",
150                        f.entries()
151                    ),
152                    FightKind::Wave => "its one mob entry declares no `name`".to_string(),
153                    FightKind::Actor => "the actor declares no `name`".to_string(),
154                };
155                d.push(Diagnostic::error(
156                    codes::HEALTH_BAR_UNTITLED,
157                    "quests",
158                    bar_path.clone(),
159                    format!(
160                        "`health_bar` on {} `{}` has nothing to title it: it states no `title`, \
161                         and {why}. The engine owns no wording that is right for every fight, \
162                         so it does not invent one. State `title`, or name the body.",
163                        f.word(),
164                        f.id()
165                    ),
166                ));
167            }
168            None => {}
169        }
170        if f.kind() == FightKind::Actor && !f.vulnerable() && !unleashed.contains(f.id()) {
171            d.push(Diagnostic::error(
172                codes::HEALTH_BAR_STILL,
173                "quests",
174                bar_path.clone(),
175                format!(
176                    "`health_bar` on actor `{}` is over a body whose health can never move: the \
177                     actor is not `vulnerable`, so its puppet is invulnerable, and no \
178                     `unleash-actor` names it, so no body that can be hurt ever stands in its \
179                     place. The bar would sit full for as long as the puppet stands. Unleash it \
180                     somewhere, mark it `vulnerable: true`, or remove the bar.",
181                    f.id()
182                ),
183            ));
184        }
185    }
186}
187
188/// What the health-bar rules examined on one campaign, with the denominator
189/// (CLAUDE.md, vacuity): how many fights were declared, how many carry a bar,
190/// how many are billed `boss` and how many of those carry one.
191#[derive(Clone, Debug, Default, PartialEq, Eq)]
192pub struct HealthBarBinding {
193    /// Fights declared — waves plus actors. The denominator.
194    pub fights: usize,
195    /// Waves declared.
196    pub waves: usize,
197    /// Actors declared.
198    pub actors: usize,
199    /// Fights carrying a bar — the objects the rules bind.
200    pub with_bar: usize,
201    /// Fights billed `boss`.
202    pub boss: usize,
203    /// Boss-billed fights carrying a bar.
204    pub boss_with_bar: usize,
205    /// Refusals raised (`DW0100` on a bar's `range`, `DW0909`, `DW0910`).
206    pub refused: usize,
207    /// Advisories raised (`DW0912`).
208    pub advised: usize,
209}
210
211impl HealthBarBinding {
212    /// Count what [`health_bar_checks`] examines on `c`.
213    pub fn of(c: &Campaign) -> Self {
214        let mut b = HealthBarBinding::default();
215        for (_, f) in fights(c) {
216            b.fights += 1;
217            match f.kind() {
218                FightKind::Wave => b.waves += 1,
219                FightKind::Actor => b.actors += 1,
220            }
221            let boss = f.tier() == Some(EncounterTier::Boss);
222            if boss {
223                b.boss += 1;
224            }
225            if f.health_bar().is_some() {
226                b.with_bar += 1;
227                if boss {
228                    b.boss_with_bar += 1;
229                }
230            }
231        }
232        let mut d = Vec::new();
233        health_bar_checks(c, &mut d);
234        b.advised = d
235            .iter()
236            .filter(|x| x.code == codes::HEALTH_BAR_ADVISED.id())
237            .count();
238        b.refused = d.len() - b.advised;
239        b
240    }
241
242    /// The one line this rule owes its reader.
243    pub fn line(&self) -> String {
244        format!(
245            "health-bar binding: {} of {} fight(s) carry a bar ({} wave(s), {} actor(s) \
246             declared); {} of {} boss-billed fight(s) carry one; {} refused (DW0100/DW0909/\
247             DW0910), {} advised (DW0912).",
248            self.with_bar,
249            self.fights,
250            self.waves,
251            self.actors,
252            self.boss_with_bar,
253            self.boss,
254            self.refused,
255            self.advised
256        )
257    }
258}