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    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
153/// Parse the script into `(ability_id, ordered pushes)`. Returns `Err` on any
154/// statement that is not part of the covered family. The ability id is taken
155/// from the `get_ability_info("<id>", ...)` / `get_ability("<id>")` call; literal
156/// scripts have no id.
157fn parse_script(script: &str) -> anyhow::Result<(Option<AbilityIdRef>, Vec<Push>)> {
158    // Strip line comments, then split on `;` so leading `import` / `let`
159    // statements and the pushes are handled uniformly.
160    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        // Non-push statements: the `content` import and the `ability_info` /
179        // `ability_tpl` `let` bindings. Extract the ability id from them.
180        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            // A `let` we recognize as an ability-info binding is fine; any other
188            // `let` is unexpected — but the only `let`s in this family bind
189            // `ability_tpl`/`ability_info`, so accept and move on.
190            continue;
191        }
192
193        // Push statements.
194        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
206/// Parse the argument of a single `Result.push(<inner>)`.
207fn parse_push(inner: &str) -> anyhow::Result<Push> {
208    // floor(ability_info.<field> * 100)
209    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    // bare ability_info.<field>
227    if let Some(field) = inner.strip_prefix("ability_info.") {
228        return Ok(Push::IntField(field.trim().to_string()));
229    }
230
231    // integer literal
232    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
239/// Pull the ability reference out of a `let ... = content::get_ability_info(<arg>,
240/// ...)` or `... content::get_ability(<arg>)` binding, where `<arg>` is either a
241/// quoted literal uuid or `$.id` (the self-reference every shipped script uses).
242/// Returns `None` if this binding is not such a call.
243fn 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            // `get_ability("<literal uuid>")`
248            if let Some(quoted) = after.strip_prefix('"') {
249                let end = quoted.find('"')?;
250                return uuid::Uuid::parse_str(&quoted[..end])
251                    .ok()
252                    .map(AbilityIdRef::Literal);
253            }
254            // `get_ability($.id)` — self-reference, resolved from the ctx.
255            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    /// Regression: every shipped ability `description_values_script` reads its own
309    /// info via `content::get_ability($.id)` (a self-reference variable, NOT a
310    /// quoted literal). The parser must recognise it as `SelfId` so the value
311    /// computation resolves the ability id from the ctx — otherwise the id is
312    /// `None`, the `floor(ability_info.*)` pushes error, and `%s%` placeholders in
313    /// the description are never filled.
314    #[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        // No config needed for the literal-only path.
342        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}
354// Native function for the `talent_description_values` category — computes the
355// values for a talent's `description_values_script`.
356//
357// Follows the `power` reference shape: a typed `*Ctx`, a `*Fn` alias, native
358// impl(s), and a `register`.
359//
360// ## What these scripts compute
361// Every talent's `description_values_script` observed in
362// `overlord/admin/config/scripts/content.templates.talents.*` is the single
363// canonical form:
364//
365//
366// where `TalentLevel` is the `i64` constant pushed into scope by the `run_*`
367// evaluates `TalentLevel * N` as `i64`, resolves the `push(i64)` overload
368// ([`crate::script::DescriptionValuesVec::push_int`]) and stores it as
369// `value as f64`. The slot output is `Vec<f64>` (one element per `push`).
370//
371// Because the multiplier `N` lives in the script *source* (not in any typed
372// scope value), the native port must read the script text. The [`Ctx`] carries
373// the raw script alongside the `talent_level`, mirroring exactly the two
374// N)` statements and reproduce the same `(talent_level * N) as f64` push order.
375//
376// If a script uses any other shape (a non-canonical expression, floats, helper
377// calls, etc.) the parser returns `Err`, surfacing precisely the scripts whose
378// shape this port does not yet cover, rather than silently mis-evaluating them.
379
380/// Inputs available to a `talent_description_values` native fn — the same two
381/// `TalentLevel` constant and the script source (the multipliers `N` live in
382/// the source text). The `scope_setter` for this slot is the identity closure
383/// (`compute_description_values_for_talent`), so there are no other scope
384/// variables to mirror.
385pub struct TalentDescriptionValuesCtx<'a> {
386    pub talent_level: i64,
387    pub script: &'a str,
388}
389
390/// Signature of a `talent_description_values` native fn. Free `fn` (no captured
391/// state) so it is `Copy`; runtime context arrives via [`TalentDescriptionValuesCtx`].
392pub type TalentDescriptionValuesFn = fn(&TalentDescriptionValuesCtx) -> anyhow::Result<Vec<f64>>;
393
394/// Native port of `run_talent_description_values_calculate` for the canonical
395/// each push contributes `(TalentLevel * N) as f64`, in source order.
396pub 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        // `value as f64` (DescriptionValuesVec::push_int).
401        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
411/// Parse the script into the ordered list of integer multipliers `N`, one per
412/// `Result.push(TalentLevel * N);` statement. Returns `Err` on any statement
413/// that is not exactly this canonical shape, so non-covered script forms are
414/// flagged rather than silently mis-evaluated.
415fn canonical_statements(script: &str) -> anyhow::Result<Vec<i64>> {
416    // Strip line comments (`// ...`) so trailing comments don't break parsing;
417    // the observed scripts have none, but be conservative.
418    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        // Expect exactly: Result.push(TalentLevel * N)
435        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        // inner should be: TalentLevel * N
443        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}