1use 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
13pub 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#[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#[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 pub fn version(&self) -> &str {
39 &self.version
40 }
41 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 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 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
70pub 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
181pub 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#[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 {}