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}