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 if field == "effect_duration" {
147 return info
148 .effect_duration
149 .map(|d| d.floor() as i64)
150 .ok_or_else(|| anyhow::anyhow!("ability_info.effect_duration is absent"));
151 }
152 let v = match field {
153 "projectiles" => info.projectiles,
154 "duration" => info.duration,
155 other => anyhow::bail!("unknown i64 ability_info field {other:?}"),
156 };
157 v.ok_or_else(|| anyhow::anyhow!("ability_info.{field} is absent for this ability"))
158}
159
160fn parse_script(script: &str) -> anyhow::Result<(Option<AbilityIdRef>, Vec<Push>)> {
165 let cleaned: String = script
168 .lines()
169 .map(|line| match line.find("//") {
170 Some(idx) => &line[..idx],
171 None => line,
172 })
173 .collect::<Vec<_>>()
174 .join("\n");
175
176 let mut ability_id: Option<AbilityIdRef> = None;
177 let mut pushes = Vec::new();
178
179 for raw in cleaned.split(';') {
180 let stmt = raw.trim();
181 if stmt.is_empty() {
182 continue;
183 }
184
185 if stmt.starts_with("import ") {
188 continue;
189 }
190 if let Some(rest) = stmt.strip_prefix("let ") {
191 if let Some(id) = extract_ability_id(rest) {
192 ability_id = Some(id);
193 }
194 continue;
198 }
199
200 let inner = stmt
202 .strip_prefix("Result.push(")
203 .and_then(|s| s.strip_suffix(')'))
204 .ok_or_else(|| anyhow::anyhow!("non-canonical description statement: {stmt:?}"))?
205 .trim();
206
207 pushes.push(parse_push(inner)?);
208 }
209
210 Ok((ability_id, pushes))
211}
212
213fn parse_push(inner: &str) -> anyhow::Result<Push> {
215 if let Some(rest) = inner.strip_prefix("floor(") {
217 let body = rest
218 .strip_suffix(')')
219 .ok_or_else(|| anyhow::anyhow!("unbalanced floor(...): {inner:?}"))?
220 .trim();
221 let field = body
222 .strip_prefix("ability_info.")
223 .and_then(|s| s.trim_end().strip_suffix("100"))
224 .map(|s| s.trim_end())
225 .and_then(|s| s.strip_suffix('*'))
226 .map(|s| s.trim().to_string())
227 .ok_or_else(|| {
228 anyhow::anyhow!("floor body is not `ability_info.<field> * 100`: {body:?}")
229 })?;
230 return Ok(Push::FloorField(field));
231 }
232
233 if let Some(field) = inner.strip_prefix("ability_info.") {
235 return Ok(Push::IntField(field.trim().to_string()));
236 }
237
238 if let Ok(n) = inner.parse::<i64>() {
240 return Ok(Push::Literal(n));
241 }
242
243 anyhow::bail!("unsupported description push argument: {inner:?}")
244}
245
246fn extract_ability_id(rest: &str) -> Option<AbilityIdRef> {
251 for marker in ["get_ability_info(", "get_ability("] {
252 if let Some(idx) = rest.find(marker) {
253 let after = rest[idx + marker.len()..].trim_start();
254 if let Some(quoted) = after.strip_prefix('"') {
256 let end = quoted.find('"')?;
257 return uuid::Uuid::parse_str("ed[..end])
258 .ok()
259 .map(AbilityIdRef::Literal);
260 }
261 if after.starts_with('$') {
263 return Some(AbilityIdRef::SelfId);
264 }
265 return None;
266 }
267 }
268 None
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn parses_literal_only() {
277 let (id, pushes) = parse_script("Result.push(100);\nResult.push(2);").unwrap();
278 assert!(id.is_none());
279 assert_eq!(pushes.len(), 2);
280 assert!(matches!(pushes[0], Push::Literal(100)));
281 assert!(matches!(pushes[1], Push::Literal(2)));
282 }
283
284 #[test]
285 fn parses_get_ability_info_floor() {
286 let script = "import \"content\" as content;\n\
287 let ability_info = content::get_ability_info(\"019584aa-5bde-7ac2-8850-076dafdc4603\", AbilityLevel);\n\
288 Result.push(floor(ability_info.damage * 100));\n\
289 Result.push(floor(ability_info.crit_chance_bonus * 100));";
290 let (id, pushes) = parse_script(script).unwrap();
291 assert!(matches!(
292 id,
293 Some(AbilityIdRef::Literal(u)) if u.to_string() == "019584aa-5bde-7ac2-8850-076dafdc4603"
294 ));
295 assert_eq!(pushes.len(), 2);
296 assert!(matches!(&pushes[0], Push::FloorField(f) if f == "damage"));
297 assert!(matches!(&pushes[1], Push::FloorField(f) if f == "crit_chance_bonus"));
298 }
299
300 #[test]
301 fn parses_get_ability_tpl_form() {
302 let script = "import \"content\" as content;\n\
303 let ability_tpl = content::get_ability(\"0194d64e-20f2-75e5-89c8-4cb812672485\");\n\
304 let ability_info = ability_tpl.ability_info(AbilityLevel);\n\
305 Result.push(floor(ability_info.damage * 100));";
306 let (id, pushes) = parse_script(script).unwrap();
307 assert!(matches!(
308 id,
309 Some(AbilityIdRef::Literal(u)) if u.to_string() == "0194d64e-20f2-75e5-89c8-4cb812672485"
310 ));
311 assert_eq!(pushes.len(), 1);
312 assert!(matches!(&pushes[0], Push::FloorField(f) if f == "damage"));
313 }
314
315 #[test]
322 fn parses_get_ability_self_id_form() {
323 let script = "import \"content\" as content;\n\
324 let ability_tpl = content::get_ability($.id);\n\
325 let ability_info = ability_tpl.ability_info(AbilityLevel);\n\
326 Result.push(floor(ability_info.damage * 100));";
327 let (id, pushes) = parse_script(script).unwrap();
328 assert!(matches!(id, Some(AbilityIdRef::SelfId)));
329 assert_eq!(pushes.len(), 1);
330 assert!(matches!(&pushes[0], Push::FloorField(f) if f == "damage"));
331 }
332
333 #[test]
334 fn parses_bare_int_field() {
335 let script = "import \"content\" as content;\n\
336 let ability_info = content::get_ability_info(\"019589e6-f9dd-7b22-8d39-5350e95aaf69\", AbilityLevel);\n\
337 Result.push(ability_info.projectiles);\n\
338 Result.push(floor(ability_info.damage * 100));";
339 let (_id, pushes) = parse_script(script).unwrap();
340 assert_eq!(pushes.len(), 2);
341 assert!(matches!(&pushes[0], Push::IntField(f) if f == "projectiles"));
342 assert!(matches!(&pushes[1], Push::FloorField(f) if f == "damage"));
343 }
344
345 #[test]
346 fn literal_values_are_pushed_as_f64() {
347 let ctx_script = "Result.push(100);\nResult.push(5);";
348 let (id, pushes) = parse_script(ctx_script).unwrap();
350 assert!(id.is_none());
351 let out: Vec<f64> = pushes
352 .into_iter()
353 .map(|p| match p {
354 Push::Literal(n) => n as f64,
355 _ => unreachable!(),
356 })
357 .collect();
358 assert_eq!(out, vec![100.0, 5.0]);
359 }
360}
361pub struct TalentDescriptionValuesCtx<'a> {
393 pub talent_level: i64,
394 pub script: &'a str,
395}
396
397pub type TalentDescriptionValuesFn = fn(&TalentDescriptionValuesCtx) -> anyhow::Result<Vec<f64>>;
400
401pub fn talent_description_values(ctx: &TalentDescriptionValuesCtx) -> anyhow::Result<Vec<f64>> {
404 let mut out: Vec<f64> = Vec::new();
405
406 for stmt in canonical_statements(ctx.script)? {
407 let value = ctx
409 .talent_level
410 .checked_mul(stmt)
411 .ok_or_else(|| anyhow::anyhow!("talent_level * multiplier overflowed i64"))?;
412 out.push(value as f64);
413 }
414
415 Ok(out)
416}
417
418fn canonical_statements(script: &str) -> anyhow::Result<Vec<i64>> {
423 let cleaned: String = script
426 .lines()
427 .map(|line| match line.find("//") {
428 Some(idx) => &line[..idx],
429 None => line,
430 })
431 .collect::<Vec<_>>()
432 .join("\n");
433
434 let mut multipliers = Vec::new();
435 for raw in cleaned.split(';') {
436 let stmt = raw.trim();
437 if stmt.is_empty() {
438 continue;
439 }
440
441 let inner = stmt
443 .strip_prefix("Result.push(")
444 .and_then(|s| s.strip_suffix(')'))
445 .ok_or_else(|| {
446 anyhow::anyhow!("non-canonical talent description statement: {stmt:?}")
447 })?;
448
449 let rest = inner.trim().strip_prefix("TalentLevel").ok_or_else(|| {
451 anyhow::anyhow!("statement does not start with TalentLevel: {inner:?}")
452 })?;
453 let mul = rest.trim().strip_prefix('*').ok_or_else(|| {
454 anyhow::anyhow!("statement is not a `TalentLevel * N` product: {inner:?}")
455 })?;
456 let n: i64 = mul
457 .trim()
458 .parse()
459 .map_err(|e| anyhow::anyhow!("multiplier in {inner:?} is not an i64 literal: {e}"))?;
460 multipliers.push(n);
461 }
462
463 Ok(multipliers)
464}
465
466#[cfg(test)]
467mod talent_tests {
468 use super::*;
469
470 fn run(script: &str, level: i64) -> anyhow::Result<Vec<f64>> {
471 talent_description_values(&TalentDescriptionValuesCtx {
472 talent_level: level,
473 script,
474 })
475 }
476
477 #[test]
478 fn single_push_times_one() {
479 assert_eq!(run("Result.push(TalentLevel * 1);", 5).unwrap(), vec![5.0]);
480 }
481
482 #[test]
483 fn single_push_times_ten() {
484 assert_eq!(
485 run("Result.push(TalentLevel * 10);", 3).unwrap(),
486 vec![30.0]
487 );
488 }
489
490 #[test]
491 fn level_zero() {
492 assert_eq!(run("Result.push(TalentLevel * 2);", 0).unwrap(), vec![0.0]);
493 }
494
495 #[test]
496 fn whitespace_tolerant() {
497 assert_eq!(
498 run("Result.push( TalentLevel * 2 ) ;", 4).unwrap(),
499 vec![8.0]
500 );
501 }
502
503 #[test]
504 fn non_canonical_is_error() {
505 assert!(run("Result.push(TalentLevel + 1);", 1).is_err());
506 assert!(run("let x = 3;", 1).is_err());
507 }
508}