use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::EncounterTier;
use crate::diagnostic::{Diagnostic, codes};
use crate::envelope::Campaign;
use crate::fight::{Fight, FightKind, fights, unleashed_actors};
pub const MIN_RANGE: u32 = 4;
pub const MAX_RANGE: u32 = 64;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct HealthBar {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[schemars(range(min = 4, max = 64))]
pub range: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
}
impl<'a> Fight<'a> {
pub fn bar_title(&self) -> Option<&'a str> {
let bar = self.health_bar()?;
match bar.title.as_deref() {
Some(t) => Some(t),
None => self.own_name(),
}
}
}
pub fn health_bar_checks(c: &Campaign, d: &mut Vec<Diagnostic>) {
let unleashed = unleashed_actors(c);
for (path, f) in fights(c) {
let Some(bar) = f.health_bar() else {
if f.tier() == Some(EncounterTier::Boss) {
d.push(Diagnostic::warning(
codes::HEALTH_BAR_ADVISED,
"quests",
format!("{path}/tier"),
format!(
"{kind} `{id}` is billed `tier: boss` and declares no `health_bar`, so a \
player fighting it sees nothing of how the fight is going. This is \
advice, not a refusal: the build goes on. To show it, declare \
`health_bar: {{ range: <blocks> }}` on the {kind} — a bar titled with \
its name, over its bodies' total health, drawn for every player within \
`range` blocks of it.",
kind = f.word(),
id = f.id(),
),
));
}
continue;
};
let bar_path = format!("{path}/health_bar");
if !(MIN_RANGE..=MAX_RANGE).contains(&bar.range) {
d.push(Diagnostic::error(
codes::SCHEMA,
"quests",
format!("{bar_path}/range"),
format!(
"`health_bar` `range` {} on {} `{}` is outside {MIN_RANGE}..={MAX_RANGE}. It \
is the distance, in blocks, at which a player standing near a live body of \
the fight sees its bar: below {MIN_RANGE} the bar appears only once the \
player is already in contact, and past {MAX_RANGE} it is drawn across \
rooms that are not the fight's. State the arena's size in \
{MIN_RANGE}..={MAX_RANGE}.",
bar.range,
f.word(),
f.id()
),
));
}
match bar.title.as_deref() {
Some(t) if t.trim().is_empty() => d.push(Diagnostic::error(
codes::HEALTH_BAR_UNTITLED,
"quests",
format!("{bar_path}/title"),
format!(
"`health_bar` `title` on {} `{}` is blank, so the bar would be drawn with \
nothing on it. Write the title the player reads over the bar, or remove \
`title` to draw the fight's own name.",
f.word(),
f.id()
),
)),
Some(_) => {}
None if f.own_name().is_none() => {
let why = match f.kind() {
FightKind::Wave if f.entries() != 1 => format!(
"the wave declares {} mob entries, and no one entry's name is the \
name of the whole fight",
f.entries()
),
FightKind::Wave => "its one mob entry declares no `name`".to_string(),
FightKind::Actor => "the actor declares no `name`".to_string(),
};
d.push(Diagnostic::error(
codes::HEALTH_BAR_UNTITLED,
"quests",
bar_path.clone(),
format!(
"`health_bar` on {} `{}` has nothing to title it: it states no `title`, \
and {why}. The engine owns no wording that is right for every fight, \
so it does not invent one. State `title`, or name the body.",
f.word(),
f.id()
),
));
}
None => {}
}
if f.kind() == FightKind::Actor && !f.vulnerable() && !unleashed.contains(f.id()) {
d.push(Diagnostic::error(
codes::HEALTH_BAR_STILL,
"quests",
bar_path.clone(),
format!(
"`health_bar` on actor `{}` is over a body whose health can never move: the \
actor is not `vulnerable`, so its puppet is invulnerable, and no \
`unleash-actor` names it, so no body that can be hurt ever stands in its \
place. The bar would sit full for as long as the puppet stands. Unleash it \
somewhere, mark it `vulnerable: true`, or remove the bar.",
f.id()
),
));
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct HealthBarBinding {
pub fights: usize,
pub waves: usize,
pub actors: usize,
pub with_bar: usize,
pub boss: usize,
pub boss_with_bar: usize,
pub refused: usize,
pub advised: usize,
}
impl HealthBarBinding {
pub fn of(c: &Campaign) -> Self {
let mut b = HealthBarBinding::default();
for (_, f) in fights(c) {
b.fights += 1;
match f.kind() {
FightKind::Wave => b.waves += 1,
FightKind::Actor => b.actors += 1,
}
let boss = f.tier() == Some(EncounterTier::Boss);
if boss {
b.boss += 1;
}
if f.health_bar().is_some() {
b.with_bar += 1;
if boss {
b.boss_with_bar += 1;
}
}
}
let mut d = Vec::new();
health_bar_checks(c, &mut d);
b.advised = d
.iter()
.filter(|x| x.code == codes::HEALTH_BAR_ADVISED.id())
.count();
b.refused = d.len() - b.advised;
b
}
pub fn line(&self) -> String {
format!(
"health-bar binding: {} of {} fight(s) carry a bar ({} wave(s), {} actor(s) \
declared); {} of {} boss-billed fight(s) carry one; {} refused (DW0100/DW0909/\
DW0910), {} advised (DW0912).",
self.with_bar,
self.fights,
self.waves,
self.actors,
self.boss_with_bar,
self.boss,
self.refused,
self.advised
)
}
}