overlord_event_system/behaviors/
ui_values.rs1use configs::game_config::GameConfig;
32
33use crate::mechanics::content::{self, AbilityInfo};
34use crate::mechanics::content_lookups::ContentLookups;
35
36pub struct DescriptionValuesCtx<'a> {
42 pub ability_level: i64,
43 pub ability_template_id: uuid::Uuid,
46 pub script: &'a str,
47 pub config: &'a GameConfig,
48 pub lookups: &'a ContentLookups,
49}
50
51#[derive(Clone, Copy)]
53enum AbilityIdRef {
54 Literal(uuid::Uuid),
56 SelfId,
60}
61
62pub type DescriptionValuesFn = fn(&DescriptionValuesCtx) -> anyhow::Result<Vec<f64>>;
65
66enum Push {
68 Literal(i64),
70 FloorField(String),
73 IntField(String),
76}
77
78pub fn description_values(ctx: &DescriptionValuesCtx) -> anyhow::Result<Vec<f64>> {
82 let (ability_id_ref, pushes) = parse_script(ctx.script)?;
83
84 let ability_id: Option<uuid::Uuid> = ability_id_ref.map(|r| match r {
87 AbilityIdRef::Literal(id) => id,
88 AbilityIdRef::SelfId => ctx.ability_template_id,
89 });
90
91 let info: Option<AbilityInfo> = match ability_id {
94 Some(id) => Some(content::ability_info(
95 ctx.config,
96 ctx.lookups,
97 id,
98 ctx.ability_level,
99 )?),
100 None => None,
101 };
102
103 let mut out: Vec<f64> = Vec::with_capacity(pushes.len());
104 for push in pushes {
105 match push {
106 Push::Literal(n) => out.push(n as f64),
107 Push::FloorField(field) => {
108 let info = info.as_ref().ok_or_else(|| {
109 anyhow::anyhow!("ability_info field push without an ability id")
110 })?;
111 let v = read_f64_field(info, &field)?;
112 out.push((v * 100.0).floor());
113 }
114 Push::IntField(field) => {
115 let info = info.as_ref().ok_or_else(|| {
116 anyhow::anyhow!("ability_info field push without an ability id")
117 })?;
118 let v = read_i64_field(info, &field)?;
119 out.push(v as f64);
120 }
121 }
122 }
123
124 Ok(out)
125}
126
127fn read_f64_field(info: &AbilityInfo, field: &str) -> anyhow::Result<f64> {
131 let v = match field {
132 "damage" => info.damage,
133 "dot" => info.dot,
134 "hot" => info.hot,
135 "effect_duration" => info.effect_duration,
136 "crit_chance_bonus" => info.crit_chance_bonus,
137 "vampiric" => info.vampiric,
138 other => anyhow::bail!("unknown f64 ability_info field {other:?}"),
139 };
140 v.ok_or_else(|| anyhow::anyhow!("ability_info.{field} is absent for this ability"))
141}
142
143fn read_i64_field(info: &AbilityInfo, field: &str) -> anyhow::Result<i64> {
145 let v = match field {
146 "projectiles" => info.projectiles,
147 "duration" => info.duration,
148 other => anyhow::bail!("unknown i64 ability_info field {other:?}"),
149 };
150 v.ok_or_else(|| anyhow::anyhow!("ability_info.{field} is absent for this ability"))
151}
152
153fn parse_script(script: &str) -> anyhow::Result<(Option<AbilityIdRef>, Vec<Push>)> {
158 let cleaned: String = script
161 .lines()
162 .map(|line| match line.find("//") {
163 Some(idx) => &line[..idx],
164 None => line,
165 })
166 .collect::<Vec<_>>()
167 .join("\n");
168
169 let mut ability_id: Option<AbilityIdRef> = None;
170 let mut pushes = Vec::new();
171
172 for raw in cleaned.split(';') {
173 let stmt = raw.trim();
174 if stmt.is_empty() {
175 continue;
176 }
177
178 if stmt.starts_with("import ") {
181 continue;
182 }
183 if let Some(rest) = stmt.strip_prefix("let ") {
184 if let Some(id) = extract_ability_id(rest) {
185 ability_id = Some(id);
186 }
187 continue;
191 }
192
193 let inner = stmt
195 .strip_prefix("Result.push(")
196 .and_then(|s| s.strip_suffix(')'))
197 .ok_or_else(|| anyhow::anyhow!("non-canonical description statement: {stmt:?}"))?
198 .trim();
199
200 pushes.push(parse_push(inner)?);
201 }
202
203 Ok((ability_id, pushes))
204}
205
206fn parse_push(inner: &str) -> anyhow::Result<Push> {
208 if let Some(rest) = inner.strip_prefix("floor(") {
210 let body = rest
211 .strip_suffix(')')
212 .ok_or_else(|| anyhow::anyhow!("unbalanced floor(...): {inner:?}"))?
213 .trim();
214 let field = body
215 .strip_prefix("ability_info.")
216 .and_then(|s| s.trim_end().strip_suffix("100"))
217 .map(|s| s.trim_end())
218 .and_then(|s| s.strip_suffix('*'))
219 .map(|s| s.trim().to_string())
220 .ok_or_else(|| {
221 anyhow::anyhow!("floor body is not `ability_info.<field> * 100`: {body:?}")
222 })?;
223 return Ok(Push::FloorField(field));
224 }
225
226 if let Some(field) = inner.strip_prefix("ability_info.") {
228 return Ok(Push::IntField(field.trim().to_string()));
229 }
230
231 if let Ok(n) = inner.parse::<i64>() {
233 return Ok(Push::Literal(n));
234 }
235
236 anyhow::bail!("unsupported description push argument: {inner:?}")
237}
238
239fn extract_ability_id(rest: &str) -> Option<AbilityIdRef> {
244 for marker in ["get_ability_info(", "get_ability("] {
245 if let Some(idx) = rest.find(marker) {
246 let after = rest[idx + marker.len()..].trim_start();
247 if let Some(quoted) = after.strip_prefix('"') {
249 let end = quoted.find('"')?;
250 return uuid::Uuid::parse_str("ed[..end])
251 .ok()
252 .map(AbilityIdRef::Literal);
253 }
254 if after.starts_with('$') {
256 return Some(AbilityIdRef::SelfId);
257 }
258 return None;
259 }
260 }
261 None
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn parses_literal_only() {
270 let (id, pushes) = parse_script("Result.push(100);\nResult.push(2);").unwrap();
271 assert!(id.is_none());
272 assert_eq!(pushes.len(), 2);
273 assert!(matches!(pushes[0], Push::Literal(100)));
274 assert!(matches!(pushes[1], Push::Literal(2)));
275 }
276
277 #[test]
278 fn parses_get_ability_info_floor() {
279 let script = "import \"content\" as content;\n\
280 let ability_info = content::get_ability_info(\"019584aa-5bde-7ac2-8850-076dafdc4603\", AbilityLevel);\n\
281 Result.push(floor(ability_info.damage * 100));\n\
282 Result.push(floor(ability_info.crit_chance_bonus * 100));";
283 let (id, pushes) = parse_script(script).unwrap();
284 assert!(matches!(
285 id,
286 Some(AbilityIdRef::Literal(u)) if u.to_string() == "019584aa-5bde-7ac2-8850-076dafdc4603"
287 ));
288 assert_eq!(pushes.len(), 2);
289 assert!(matches!(&pushes[0], Push::FloorField(f) if f == "damage"));
290 assert!(matches!(&pushes[1], Push::FloorField(f) if f == "crit_chance_bonus"));
291 }
292
293 #[test]
294 fn parses_get_ability_tpl_form() {
295 let script = "import \"content\" as content;\n\
296 let ability_tpl = content::get_ability(\"0194d64e-20f2-75e5-89c8-4cb812672485\");\n\
297 let ability_info = ability_tpl.ability_info(AbilityLevel);\n\
298 Result.push(floor(ability_info.damage * 100));";
299 let (id, pushes) = parse_script(script).unwrap();
300 assert!(matches!(
301 id,
302 Some(AbilityIdRef::Literal(u)) if u.to_string() == "0194d64e-20f2-75e5-89c8-4cb812672485"
303 ));
304 assert_eq!(pushes.len(), 1);
305 assert!(matches!(&pushes[0], Push::FloorField(f) if f == "damage"));
306 }
307
308 #[test]
315 fn parses_get_ability_self_id_form() {
316 let script = "import \"content\" as content;\n\
317 let ability_tpl = content::get_ability($.id);\n\
318 let ability_info = ability_tpl.ability_info(AbilityLevel);\n\
319 Result.push(floor(ability_info.damage * 100));";
320 let (id, pushes) = parse_script(script).unwrap();
321 assert!(matches!(id, Some(AbilityIdRef::SelfId)));
322 assert_eq!(pushes.len(), 1);
323 assert!(matches!(&pushes[0], Push::FloorField(f) if f == "damage"));
324 }
325
326 #[test]
327 fn parses_bare_int_field() {
328 let script = "import \"content\" as content;\n\
329 let ability_info = content::get_ability_info(\"019589e6-f9dd-7b22-8d39-5350e95aaf69\", AbilityLevel);\n\
330 Result.push(ability_info.projectiles);\n\
331 Result.push(floor(ability_info.damage * 100));";
332 let (_id, pushes) = parse_script(script).unwrap();
333 assert_eq!(pushes.len(), 2);
334 assert!(matches!(&pushes[0], Push::IntField(f) if f == "projectiles"));
335 assert!(matches!(&pushes[1], Push::FloorField(f) if f == "damage"));
336 }
337
338 #[test]
339 fn literal_values_are_pushed_as_f64() {
340 let ctx_script = "Result.push(100);\nResult.push(5);";
341 let (id, pushes) = parse_script(ctx_script).unwrap();
343 assert!(id.is_none());
344 let out: Vec<f64> = pushes
345 .into_iter()
346 .map(|p| match p {
347 Push::Literal(n) => n as f64,
348 _ => unreachable!(),
349 })
350 .collect();
351 assert_eq!(out, vec![100.0, 5.0]);
352 }
353}
354pub struct TalentDescriptionValuesCtx<'a> {
386 pub talent_level: i64,
387 pub script: &'a str,
388}
389
390pub type TalentDescriptionValuesFn = fn(&TalentDescriptionValuesCtx) -> anyhow::Result<Vec<f64>>;
393
394pub fn talent_description_values(ctx: &TalentDescriptionValuesCtx) -> anyhow::Result<Vec<f64>> {
397 let mut out: Vec<f64> = Vec::new();
398
399 for stmt in canonical_statements(ctx.script)? {
400 let value = ctx
402 .talent_level
403 .checked_mul(stmt)
404 .ok_or_else(|| anyhow::anyhow!("talent_level * multiplier overflowed i64"))?;
405 out.push(value as f64);
406 }
407
408 Ok(out)
409}
410
411fn canonical_statements(script: &str) -> anyhow::Result<Vec<i64>> {
416 let cleaned: String = script
419 .lines()
420 .map(|line| match line.find("//") {
421 Some(idx) => &line[..idx],
422 None => line,
423 })
424 .collect::<Vec<_>>()
425 .join("\n");
426
427 let mut multipliers = Vec::new();
428 for raw in cleaned.split(';') {
429 let stmt = raw.trim();
430 if stmt.is_empty() {
431 continue;
432 }
433
434 let inner = stmt
436 .strip_prefix("Result.push(")
437 .and_then(|s| s.strip_suffix(')'))
438 .ok_or_else(|| {
439 anyhow::anyhow!("non-canonical talent description statement: {stmt:?}")
440 })?;
441
442 let rest = inner.trim().strip_prefix("TalentLevel").ok_or_else(|| {
444 anyhow::anyhow!("statement does not start with TalentLevel: {inner:?}")
445 })?;
446 let mul = rest.trim().strip_prefix('*').ok_or_else(|| {
447 anyhow::anyhow!("statement is not a `TalentLevel * N` product: {inner:?}")
448 })?;
449 let n: i64 = mul
450 .trim()
451 .parse()
452 .map_err(|e| anyhow::anyhow!("multiplier in {inner:?} is not an i64 literal: {e}"))?;
453 multipliers.push(n);
454 }
455
456 Ok(multipliers)
457}
458
459#[cfg(test)]
460mod talent_tests {
461 use super::*;
462
463 fn run(script: &str, level: i64) -> anyhow::Result<Vec<f64>> {
464 talent_description_values(&TalentDescriptionValuesCtx {
465 talent_level: level,
466 script,
467 })
468 }
469
470 #[test]
471 fn single_push_times_one() {
472 assert_eq!(run("Result.push(TalentLevel * 1);", 5).unwrap(), vec![5.0]);
473 }
474
475 #[test]
476 fn single_push_times_ten() {
477 assert_eq!(
478 run("Result.push(TalentLevel * 10);", 3).unwrap(),
479 vec![30.0]
480 );
481 }
482
483 #[test]
484 fn level_zero() {
485 assert_eq!(run("Result.push(TalentLevel * 2);", 0).unwrap(), vec![0.0]);
486 }
487
488 #[test]
489 fn whitespace_tolerant() {
490 assert_eq!(
491 run("Result.push( TalentLevel * 2 ) ;", 4).unwrap(),
492 vec![8.0]
493 );
494 }
495
496 #[test]
497 fn non_canonical_is_error() {
498 assert!(run("Result.push(TalentLevel + 1);", 1).is_err());
499 assert!(run("let x = 3;", 1).is_err());
500 }
501}