Skip to main content

workshop_rs/
hero_values.rs

1//! Versioned hero base values: health pools, ability cooldowns, and ultimate
2//! costs, pinned to one game client version per file (`docs/hero-values.md`).
3
4use std::collections::{HashMap, HashSet};
5use std::sync::OnceLock;
6
7use serde::Deserialize;
8
9use crate::gameplay::{AbilityVariant, GameplayDataError, HeroId, LogicalSlot};
10
11const FORMAT: &str = "workshop-rs/hero-values-v1";
12
13/// The client version of the newest checked-in hero-values file.
14pub const CURRENT_VERSION: &str = "2.25.0.0 - 154461.1";
15
16const VALUES_2_25_0_0_154461_1: &str = include_str!("gameplay/values/2.25.0.0 - 154461.1.json");
17
18/// A health pool kind: plain health, shield, or armor.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum HealthPool {
22    Health,
23    Shield,
24    Armor,
25}
26
27/// The validated hero-value set of one client version.
28#[derive(Debug)]
29pub struct HeroValues {
30    version: String,
31    health: HashMap<(HeroId, HealthPool), f64>,
32    cooldowns: HashMap<(HeroId, LogicalSlot, Option<AbilityVariant>), f64>,
33    ultimate_costs: HashMap<HeroId, f64>,
34}
35
36impl HeroValues {
37    /// The `balanceVersion` of the file this set was loaded from.
38    pub fn version(&self) -> &str {
39        &self.version
40    }
41    /// Health points of one pool, or `None` when the file holds no such value.
42    pub fn health(&self, hero: impl AsRef<str>, pool: HealthPool) -> Option<f64> {
43        self.health
44            .get(&(HeroId::new(hero.as_ref()), pool))
45            .copied()
46    }
47    /// Cooldown seconds of the ability at `slot`/`variant`, or `None` when absent.
48    pub fn cooldown(
49        &self,
50        hero: impl AsRef<str>,
51        slot: impl AsRef<str>,
52        variant: Option<&str>,
53    ) -> Option<f64> {
54        self.cooldowns
55            .get(&(
56                HeroId::new(hero.as_ref()),
57                LogicalSlot::new(slot.as_ref()),
58                variant.map(AbilityVariant::new),
59            ))
60            .copied()
61    }
62    /// Ultimate cost in points, or `None` when the file holds no such value.
63    pub fn ultimate_cost(&self, hero: impl AsRef<str>) -> Option<f64> {
64        self.ultimate_costs
65            .get(&HeroId::new(hero.as_ref()))
66            .copied()
67    }
68}
69
70/// The checked-in hero-values file for `version`, loaded and validated once.
71pub fn builtin(version: &str) -> Result<&'static HeroValues, HeroValuesError> {
72    match version {
73        CURRENT_VERSION => {
74            static DATA: OnceLock<Result<HeroValues, HeroValuesError>> = OnceLock::new();
75            DATA.get_or_init(|| load(VALUES_2_25_0_0_154461_1))
76                .as_ref()
77                .map_err(Clone::clone)
78        }
79        _ => Err(HeroValuesError::UnknownVersion {
80            version: version.to_string(),
81        }),
82    }
83}
84
85#[derive(Deserialize)]
86#[serde(rename_all = "camelCase", deny_unknown_fields)]
87struct RawFile {
88    format: String,
89    balance_version: String,
90    heroes: Vec<RawHero>,
91}
92
93#[derive(Deserialize)]
94#[serde(deny_unknown_fields)]
95struct RawHero {
96    id: HeroId,
97    values: Vec<serde_json::Value>,
98}
99
100#[derive(Deserialize)]
101#[serde(deny_unknown_fields)]
102struct RawHealth {
103    #[serde(rename = "kind")]
104    _kind: serde::de::IgnoredAny,
105    pool: HealthPool,
106    amount: f64,
107}
108
109#[derive(Deserialize)]
110#[serde(deny_unknown_fields)]
111struct RawCooldown {
112    #[serde(rename = "kind")]
113    _kind: serde::de::IgnoredAny,
114    slot: LogicalSlot,
115    variant: Option<AbilityVariant>,
116    seconds: f64,
117}
118
119#[derive(Deserialize)]
120#[serde(deny_unknown_fields)]
121struct RawUltimateCost {
122    #[serde(rename = "kind")]
123    _kind: serde::de::IgnoredAny,
124    points: f64,
125}
126
127enum RawValue {
128    Health(HealthPool, f64),
129    Cooldown(LogicalSlot, Option<AbilityVariant>, f64),
130    UltimateCost(f64),
131}
132
133fn positive(value: f64, field: &'static str) -> Result<f64, HeroValuesError> {
134    if !value.is_finite() || value <= 0.0 {
135        return Err(HeroValuesError::NonPositiveAmount { field });
136    }
137    Ok(value)
138}
139
140fn parse_value(value: serde_json::Value) -> Result<RawValue, HeroValuesError> {
141    let kind = value
142        .get("kind")
143        .and_then(|kind| kind.as_str())
144        .map(str::to_string)
145        .ok_or_else(|| HeroValuesError::Malformed("value has no string 'kind'".to_string()))?;
146    match kind.as_str() {
147        "health" => {
148            let raw: RawHealth = serde_json::from_value(value)
149                .map_err(|error| HeroValuesError::Malformed(format!("health value: {error}")))?;
150            Ok(RawValue::Health(raw.pool, positive(raw.amount, "amount")?))
151        }
152        "cooldown" => {
153            let raw: RawCooldown = serde_json::from_value(value)
154                .map_err(|error| HeroValuesError::Malformed(format!("cooldown value: {error}")))?;
155            if raw.slot.as_str().is_empty() {
156                return Err(HeroValuesError::EmptyId("slot"));
157            }
158            if raw
159                .variant
160                .as_ref()
161                .is_some_and(|variant| variant.as_str().is_empty())
162            {
163                return Err(HeroValuesError::EmptyId("variant"));
164            }
165            Ok(RawValue::Cooldown(
166                raw.slot,
167                raw.variant,
168                positive(raw.seconds, "seconds")?,
169            ))
170        }
171        "ultimateCost" => {
172            let raw: RawUltimateCost = serde_json::from_value(value).map_err(|error| {
173                HeroValuesError::Malformed(format!("ultimateCost value: {error}"))
174            })?;
175            Ok(RawValue::UltimateCost(positive(raw.points, "points")?))
176        }
177        _ => Err(HeroValuesError::UnknownKind { kind }),
178    }
179}
180
181/// Load and validate a hero-values file. Hero, slot, and variant identities are
182/// checked against the checked-in gameplay dataset.
183pub fn load(json: &str) -> Result<HeroValues, HeroValuesError> {
184    let file: RawFile = serde_json::from_str(json)
185        .map_err(|error| HeroValuesError::Malformed(error.to_string()))?;
186    if file.format != FORMAT {
187        return Err(HeroValuesError::InvalidFormat { found: file.format });
188    }
189    if file.balance_version.is_empty() {
190        return Err(HeroValuesError::EmptyId("balanceVersion"));
191    }
192    let catalog = crate::gameplay::data::builtin_ref()
193        .map_err(|error| HeroValuesError::GameplayDataUnavailable(error.clone()))?;
194
195    let mut values = HeroValues {
196        version: file.balance_version,
197        health: HashMap::new(),
198        cooldowns: HashMap::new(),
199        ultimate_costs: HashMap::new(),
200    };
201    let mut seen_heroes = HashSet::new();
202    for hero in file.heroes {
203        if hero.id.as_str().is_empty() {
204            return Err(HeroValuesError::EmptyId("hero"));
205        }
206        if !seen_heroes.insert(hero.id.clone()) {
207            return Err(HeroValuesError::DuplicateHero { hero: hero.id });
208        }
209        let record = catalog
210            .hero(&hero.id)
211            .ok_or_else(|| HeroValuesError::UnknownHero {
212                hero: hero.id.clone(),
213            })?;
214        for value in hero.values {
215            match parse_value(value)? {
216                RawValue::Health(pool, amount) => {
217                    let key = (hero.id.clone(), pool);
218                    if values.health.contains_key(&key) {
219                        return Err(HeroValuesError::DuplicateValue {
220                            hero: hero.id.clone(),
221                            value: format!("health pool '{pool:?}'"),
222                        });
223                    }
224                    values.health.insert(key, amount);
225                }
226                RawValue::Cooldown(slot, variant, seconds) => {
227                    check_cooldown_target(record, &slot, variant.as_ref())?;
228                    let key = (hero.id.clone(), slot.clone(), variant.clone());
229                    if values.cooldowns.contains_key(&key) {
230                        let target = match &variant {
231                            Some(variant) => format!("cooldown '{slot}/{variant}'"),
232                            None => format!("cooldown '{slot}'"),
233                        };
234                        return Err(HeroValuesError::DuplicateValue {
235                            hero: hero.id.clone(),
236                            value: target,
237                        });
238                    }
239                    values.cooldowns.insert(key, seconds);
240                }
241                RawValue::UltimateCost(points) => {
242                    if values.ultimate_costs.contains_key(&hero.id) {
243                        return Err(HeroValuesError::DuplicateValue {
244                            hero: hero.id.clone(),
245                            value: "ultimate cost".to_string(),
246                        });
247                    }
248                    values.ultimate_costs.insert(hero.id.clone(), points);
249                }
250            }
251        }
252    }
253    Ok(values)
254}
255
256fn check_cooldown_target(
257    hero: &crate::gameplay::Hero,
258    slot: &LogicalSlot,
259    variant: Option<&AbilityVariant>,
260) -> Result<(), HeroValuesError> {
261    match variant {
262        Some(variant) => hero
263            .ability_variant(slot, variant)
264            .map(|_| ())
265            .map_err(|_| HeroValuesError::UnknownVariant {
266                hero: hero.id().clone(),
267                slot: slot.clone(),
268                variant: variant.clone(),
269            }),
270        None => match hero.abilities_in_slot(slot).len() {
271            0 => Err(HeroValuesError::UnknownSlot {
272                hero: hero.id().clone(),
273                slot: slot.clone(),
274            }),
275            1 => Ok(()),
276            _ => Err(HeroValuesError::AmbiguousSlot {
277                hero: hero.id().clone(),
278                slot: slot.clone(),
279            }),
280        },
281    }
282}
283
284/// Validation and lookup errors for hero-values files.
285#[derive(Debug, Clone, PartialEq)]
286#[non_exhaustive]
287pub enum HeroValuesError {
288    UnknownVersion {
289        version: String,
290    },
291    Malformed(String),
292    InvalidFormat {
293        found: String,
294    },
295    UnknownKind {
296        kind: String,
297    },
298    EmptyId(&'static str),
299    NonPositiveAmount {
300        field: &'static str,
301    },
302    UnknownHero {
303        hero: HeroId,
304    },
305    UnknownSlot {
306        hero: HeroId,
307        slot: LogicalSlot,
308    },
309    AmbiguousSlot {
310        hero: HeroId,
311        slot: LogicalSlot,
312    },
313    UnknownVariant {
314        hero: HeroId,
315        slot: LogicalSlot,
316        variant: AbilityVariant,
317    },
318    DuplicateHero {
319        hero: HeroId,
320    },
321    DuplicateValue {
322        hero: HeroId,
323        value: String,
324    },
325    GameplayDataUnavailable(GameplayDataError),
326}
327
328impl std::fmt::Display for HeroValuesError {
329    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
330        match self {
331            Self::UnknownVersion { version } => {
332                write!(
333                    f,
334                    "no hero-values file is registered for version '{version}'"
335                )
336            }
337            Self::Malformed(message) => write!(f, "malformed hero values: {message}"),
338            Self::InvalidFormat { found } => {
339                write!(f, "hero-values format '{found}' is not '{FORMAT}'")
340            }
341            Self::UnknownKind { kind } => write!(f, "unknown hero value kind '{kind}'"),
342            Self::EmptyId(field) => write!(f, "hero-values field '{field}' is empty"),
343            Self::NonPositiveAmount { field } => {
344                write!(
345                    f,
346                    "hero-values field '{field}' must be a finite positive number"
347                )
348            }
349            Self::UnknownHero { hero } => {
350                write!(f, "gameplay dataset has no hero '{hero}'")
351            }
352            Self::UnknownSlot { hero, slot } => {
353                write!(f, "hero '{hero}' has no ability in slot '{slot}'")
354            }
355            Self::AmbiguousSlot { hero, slot } => write!(
356                f,
357                "hero '{hero}' has multiple abilities in slot '{slot}'; a variant is required"
358            ),
359            Self::UnknownVariant {
360                hero,
361                slot,
362                variant,
363            } => write!(
364                f,
365                "hero '{hero}' has no ability in slot '{slot}' with variant '{variant}'"
366            ),
367            Self::DuplicateHero { hero } => write!(f, "duplicate hero entry '{hero}'"),
368            Self::DuplicateValue { hero, value } => {
369                write!(f, "hero '{hero}' has a duplicate {value}")
370            }
371            Self::GameplayDataUnavailable(error) => {
372                write!(f, "gameplay dataset is unavailable: {error}")
373            }
374        }
375    }
376}
377impl std::error::Error for HeroValuesError {}