overlord_event_system/behaviors/
ui_values.rs

1//! Native function for the `description_values` category — computes the values
2//! for an ability's `description_values_script`.
3//!
4//! Follows the `power` reference shape: a typed `*Ctx`, a `*Fn` alias, a native
5//! impl, and a `register`.
6//!
7//! ## What these scripts compute
8//! Each ability's `description_values_script` (in
9//! `overlord/admin/config/scripts/content.templates.abilities.*`) is one of two
10//! shapes:
11//!
12//! 1. **Content-driven** — import `content`, fetch the ability info, then push
13//!    one or more derived fields:
14//!    Each push is either `floor(ability_info.<field> * 100)` (a `f64` →
15//!    `floor` → pushed as `f64`) or a bare `ability_info.<field>` (an integer
16//!    field like `projectiles`, pushed via the `push(i64)` overload as
17//!    `value as f64`).
18//!
19//! 2. **Literal** — no content dependency, just integer literals:
20//!
21//! The native port reads the script source (the only way to know the field
22//! order and the literal values — they live in the source, not in any typed
23//! scope value), parses the canonical statement family, and reproduces the same
24//! `Vec<f64>`. It dispatches the ability info through
25//! [`crate::mechanics::content::ability_info`] so a discrepancy points at
26//! the per-ability composition, not at this glue.
27//!
28//! Any script shape this parser does not cover returns `Err`, surfacing the
29//! uncovered script rather than silently mis-evaluating it.
30
31use configs::game_config::GameConfig;
32
33use crate::mechanics::content::{self, AbilityInfo};
34use crate::mechanics::content_lookups::ContentLookups;
35
36/// Inputs available to a `description_values` native fn — the same two inputs
37/// constant and the script source), plus the config / content lookups
38/// `content::ability_info` needs. The `scope_setter` for this slot is the
39/// identity closure (`compute_description_values_for_ability`), so there are no
40/// other scope variables to mirror.
41pub struct DescriptionValuesCtx<'a> {
42    pub ability_level: i64,
43    /// The template id of the ability being described. The shipped scripts read
44    /// their own info via `content::get_ability($.id)`, where `$.id` is this id.
45    pub ability_template_id: uuid::Uuid,
46    pub script: &'a str,
47    pub config: &'a GameConfig,
48    pub lookups: &'a ContentLookups,
49}
50
51/// Which ability's `ability_info` a `description_values` script reads.
52#[derive(Clone, Copy)]
53enum AbilityIdRef {
54    /// `content::get_ability("<literal uuid>")` — a specific ability.
55    Literal(uuid::Uuid),
56    /// `content::get_ability($.id)` — the ability being described; resolved from
57    /// [`DescriptionValuesCtx::ability_template_id`]. Every shipped script uses
58    /// this self-reference form.
59    SelfId,
60}
61
62/// Signature of a `description_values` native fn. Free `fn` (no captured state)
63/// so it is `Copy`; runtime context arrives via [`DescriptionValuesCtx`].
64pub type DescriptionValuesFn = fn(&DescriptionValuesCtx) -> anyhow::Result<Vec<f64>>;
65
66/// One parsed `Result.push(...)` statement.
67enum Push {
68    /// `Result.push(<int literal>)` — pushed via `push(i64)` → `value as f64`.
69    Literal(i64),
70    /// `Result.push(floor(ability_info.<field> * 100))` — a `f64` field times
71    /// 100, floored, pushed as `f64`.
72    FloorField(String),
73    /// `Result.push(ability_info.<field>)` — a bare integer field (e.g.
74    /// `projectiles`), pushed via `push(i64)` → `value as f64`.
75    IntField(String),
76}
77
78/// Native port of `run_description_values_calculate` for the ability
79/// `f64` fields push directly; bare integer fields and integer literals push
80/// `value as f64`.
81pub fn description_values(ctx: &DescriptionValuesCtx) -> anyhow::Result<Vec<f64>> {
82    let (ability_id_ref, pushes) = parse_script(ctx.script)?;
83
84    // Resolve the ability id the script reads: a literal `get_ability("id")`, or
85    // `$.id` (the ability being described → this template id).
86    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    // Only fetch ability info if some statement actually reads it (literal-only
92    // scripts have no content dependency / no id).
93    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
127/// Read a `f64`-typed `ability_info.<field>` (the ones pushed via `floor(... *
128/// 100)`). `Err` for absent / non-`f64` fields so an uncovered combination
129/// surfaces as a mismatch.
130fn 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
143/// Read an `i64`-typed `ability_info.<field>` (pushed bare, e.g. `projectiles`).
144fn read_i64_field(info: &AbilityInfo, field: &str) -> anyhow::Result<i64> {
145    // Buff/debuff durations display as whole seconds.
146    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
160/// Parse the script into `(ability_id, ordered pushes)`. Returns `Err` on any
161/// statement that is not part of the covered family. The ability id is taken
162/// from the `get_ability_info("<id>", ...)` / `get_ability("<id>")` call; literal
163/// scripts have no id.
164fn parse_script(script: &str) -> anyhow::Result<(Option<AbilityIdRef>, Vec<Push>)> {
165    // Strip line comments, then split on `;` so leading `import` / `let`
166    // statements and the pushes are handled uniformly.
167    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        // Non-push statements: the `content` import and the `ability_info` /
186        // `ability_tpl` `let` bindings. Extract the ability id from them.
187        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            // A `let` we recognize as an ability-info binding is fine; any other
195            // `let` is unexpected — but the only `let`s in this family bind
196            // `ability_tpl`/`ability_info`, so accept and move on.
197            continue;
198        }
199
200        // Push statements.
201        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
213/// Parse the argument of a single `Result.push(<inner>)`.
214fn parse_push(inner: &str) -> anyhow::Result<Push> {
215    // floor(ability_info.<field> * 100)
216    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    // bare ability_info.<field>
234    if let Some(field) = inner.strip_prefix("ability_info.") {
235        return Ok(Push::IntField(field.trim().to_string()));
236    }
237
238    // integer literal
239    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
246/// Pull the ability reference out of a `let ... = content::get_ability_info(<arg>,
247/// ...)` or `... content::get_ability(<arg>)` binding, where `<arg>` is either a
248/// quoted literal uuid or `$.id` (the self-reference every shipped script uses).
249/// Returns `None` if this binding is not such a call.
250fn 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            // `get_ability("<literal uuid>")`
255            if let Some(quoted) = after.strip_prefix('"') {
256                let end = quoted.find('"')?;
257                return uuid::Uuid::parse_str(&quoted[..end])
258                    .ok()
259                    .map(AbilityIdRef::Literal);
260            }
261            // `get_ability($.id)` — self-reference, resolved from the ctx.
262            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    /// Regression: every shipped ability `description_values_script` reads its own
316    /// info via `content::get_ability($.id)` (a self-reference variable, NOT a
317    /// quoted literal). The parser must recognise it as `SelfId` so the value
318    /// computation resolves the ability id from the ctx — otherwise the id is
319    /// `None`, the `floor(ability_info.*)` pushes error, and `%s%` placeholders in
320    /// the description are never filled.
321    #[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        // No config needed for the literal-only path.
349        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}
361// Native function for the `talent_description_values` category — computes the
362// values for a talent's `description_values_script`.
363//
364// Follows the `power` reference shape: a typed `*Ctx`, a `*Fn` alias, native
365// impl(s), and a `register`.
366//
367// ## What these scripts compute
368// Every talent's `description_values_script` observed in
369// `overlord/admin/config/scripts/content.templates.talents.*` is the single
370// canonical form:
371//
372//
373// where `TalentLevel` is the `i64` constant pushed into scope by the `run_*`
374// evaluates `TalentLevel * N` as `i64`, resolves the `push(i64)` overload
375// ([`crate::script::DescriptionValuesVec::push_int`]) and stores it as
376// `value as f64`. The slot output is `Vec<f64>` (one element per `push`).
377//
378// Because the multiplier `N` lives in the script *source* (not in any typed
379// scope value), the native port must read the script text. The [`Ctx`] carries
380// the raw script alongside the `talent_level`, mirroring exactly the two
381// N)` statements and reproduce the same `(talent_level * N) as f64` push order.
382//
383// If a script uses any other shape (a non-canonical expression, floats, helper
384// calls, etc.) the parser returns `Err`, surfacing precisely the scripts whose
385// shape this port does not yet cover, rather than silently mis-evaluating them.
386
387/// Inputs available to a `talent_description_values` native fn — the same two
388/// `TalentLevel` constant and the script source (the multipliers `N` live in
389/// the source text). The `scope_setter` for this slot is the identity closure
390/// (`compute_description_values_for_talent`), so there are no other scope
391/// variables to mirror.
392pub struct TalentDescriptionValuesCtx<'a> {
393    pub talent_level: i64,
394    pub script: &'a str,
395}
396
397/// Signature of a `talent_description_values` native fn. Free `fn` (no captured
398/// state) so it is `Copy`; runtime context arrives via [`TalentDescriptionValuesCtx`].
399pub type TalentDescriptionValuesFn = fn(&TalentDescriptionValuesCtx) -> anyhow::Result<Vec<f64>>;
400
401/// Native port of `run_talent_description_values_calculate` for the canonical
402/// each push contributes `(TalentLevel * N) as f64`, in source order.
403pub 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        // `value as f64` (DescriptionValuesVec::push_int).
408        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
418/// Parse the script into the ordered list of integer multipliers `N`, one per
419/// `Result.push(TalentLevel * N);` statement. Returns `Err` on any statement
420/// that is not exactly this canonical shape, so non-covered script forms are
421/// flagged rather than silently mis-evaluated.
422fn canonical_statements(script: &str) -> anyhow::Result<Vec<i64>> {
423    // Strip line comments (`// ...`) so trailing comments don't break parsing;
424    // the observed scripts have none, but be conservative.
425    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        // Expect exactly: Result.push(TalentLevel * N)
442        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        // inner should be: TalentLevel * N
450        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}