hexroll3_scroll/
renderer_env.rs

1/*
2// Copyright (C) 2020-2025 Pen, Dice & Paper
3//
4// This program is dual-licensed under the following terms:
5//
6// Option 1: (Non-Commercial) GNU Affero General Public License (AGPL)
7// This program is free software: you can redistribute it and/or modify
8// it under the terms of the GNU Affero General Public License as
9// published by the Free Software Foundation, either version 3 of the
10// License, or (at your option) any later version.
11//
12// This program is distributed in the hope that it will be useful,
13// but WITHOUT ANY WARRANTY; without even the implied warranty of
14// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15// GNU Affero General Public License for more details.
16//
17// You should have received a copy of the GNU Affero General Public License
18// along with this program. If not, see <http://www.gnu.org/licenses/>.
19//
20// Option 2: Commercial License
21// For commercial use, you are required to obtain a separate commercial
22// license. Please contact ithai at pendicepaper.com
23// for more information about commercial licensing terms.
24*/
25#![allow(clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
26
27use caith::RollResultType;
28use minijinja::Environment;
29use rand::SeedableRng;
30use std::{
31    cmp::max,
32    collections::{HashMap, HashSet},
33};
34
35use crate::instance::SandboxInstance;
36
37pub fn prepare_renderer(env: &mut Environment, instance: &SandboxInstance) {
38    env.add_filter("bulletize", func_bulletize);
39    env.add_filter("count_identical", func_count_identical);
40
41    env.add_function("appender", func_appender);
42    env.add_function("articlize", func_articlize);
43    env.add_function("capitalize", func_capitalize);
44    env.add_function("currency", func_currency(instance));
45    env.add_function("first", func_first);
46    env.add_function("float", func_float);
47    env.add_function("hex_coords", func_hex_coords);
48    env.add_function("if_plural_else", func_if_plural_else);
49    env.add_function("int", func_int);
50    env.add_function("length", func_length);
51    env.add_function("list_to_obj", func_list_to_obj);
52    env.add_function("max", func_max);
53    env.add_function("maybe", func_maybe);
54    env.add_function("plural", func_plural);
55    env.add_function("plural_with_count", func_plural_with_count);
56    env.add_function("round", func_round);
57    env.add_function("sandbox", func_sandbox(instance));
58    env.add_function("sortby", func_sortby);
59    env.add_function("stable_dice", func_stable_dice);
60    env.add_function("sum", func_sum);
61    env.add_function("title", func_capitalize);
62    env.add_function("trim", func_trim);
63    env.add_function("unique", func_unique);
64    env.add_function("html_link", func_html_link(instance));
65    env.add_function("reroller", func_reroll);
66
67    // unimplemented
68    env.add_function("begin_spoiler", func_nop_0);
69    env.add_function("end_spoiler", func_nop_0);
70    env.add_function("toc_breadcrumb", func_nop_0);
71    env.add_function("sandbox_breadcrumb", func_nop_0);
72    env.add_function("note_button", func_nop_1);
73    env.add_function("note_container", func_nop_1);
74}
75
76fn func_bulletize(value: Vec<String>, seperator: &str) -> String {
77    value.join(&format!(" &#{}; ", seperator)).to_string()
78}
79
80fn func_articlize(
81    value: minijinja::value::ViaDeserialize<serde_json::Value>,
82) -> Result<String, minijinja::Error> {
83    if let Some(noun) = value.as_str() {
84        fn is_plural(noun: &str) -> bool {
85            noun.ends_with('s') && noun != "bus" && noun != "grass" && noun != "kiss"
86        }
87        fn starts_with_vowel_sound(word: &str) -> bool {
88            let vowels = ["a", "e", "i", "o", "u"];
89            if let Some(first_char) = word.chars().next() {
90                vowels.contains(&first_char.to_lowercase().to_string().as_str())
91            } else {
92                false
93            }
94        }
95        let article = if is_plural(noun) {
96            return Ok(String::from(noun));
97        } else if starts_with_vowel_sound(noun) {
98            "an"
99        } else {
100            "a"
101        };
102        Ok(format!("{} {}", article, noun))
103    } else {
104        Err(minijinja::Error::new(
105            minijinja::ErrorKind::UndefinedError,
106            "Function articlize received a non-string value",
107        ))
108    }
109}
110
111fn func_capitalize(
112    possible_str: minijinja::value::ViaDeserialize<serde_json::Value>,
113) -> Result<minijinja::value::Value, minijinja::Error> {
114    if let Some(v) = possible_str.as_str() {
115        let mut v = v.to_string();
116        if !v.is_empty() {
117            v[0..1].make_ascii_uppercase(); // Capitalize the first character
118        }
119        Ok(minijinja::Value::from(v))
120    } else {
121        let t: serde_json::Value = possible_str.clone();
122        Ok(minijinja::value::Value::from_serialize(&t))
123    }
124}
125
126fn func_currency(
127    _instance: &SandboxInstance,
128) -> impl Fn(minijinja::value::ViaDeserialize<serde_json::Value>) -> Result<String, minijinja::Error>
129{
130    let currency_factor = 1.0;
131    move |v: minijinja::value::ViaDeserialize<serde_json::Value>| -> Result<String, minijinja::Error> {
132        if let Some(v) = v.as_f64() {
133            let v = v * currency_factor;
134            if v > 1.0 {
135                return Ok(format!("{} gp", format_with_commas(v as i64)));
136            }
137            if v > 0.1 {
138                return Ok(format!("{:.0} sp", (v * 10.0).round() as i64));
139            }
140            if v > 0.01 {
141                return Ok(format!("{:.0} cp", (v * 100.0).round() as i64));
142            }
143            return Ok(format!("{:.0} gp", v as i64));
144        }
145        Err(minijinja::Error::new(
146            minijinja::ErrorKind::UndefinedError,
147            "Currency value is not floating point",
148        ))
149    }
150}
151
152fn func_first(
153    possible_str: minijinja::value::ViaDeserialize<serde_json::Value>,
154) -> Result<minijinja::value::Value, minijinja::Error> {
155    if let Some(v) = possible_str.as_array() {
156        if let Some(first) = v.iter().next() {
157            return Ok(minijinja::Value::from_serialize(first.clone()));
158        }
159    } else if let Some(v) = possible_str.as_str() {
160        if let Some(first) = v.chars().next() {
161            return Ok(minijinja::Value::from_serialize(first));
162        }
163    }
164    Err(minijinja::Error::new(
165        minijinja::ErrorKind::UndefinedError,
166        "func_first could not pick the first item from an array",
167    ))
168}
169
170fn func_float(value: &str) -> Result<f32, minijinja::Error> {
171    if let Ok(value) = value.trim().parse::<f32>() {
172        Ok(value)
173    } else {
174        Err(minijinja::Error::new(
175            minijinja::ErrorKind::UndefinedError,
176            "Unable to convert value in func_float",
177        ))
178    }
179}
180
181fn func_int(value: &str) -> Result<i64, minijinja::Error> {
182    if let Ok(value) = value.trim().parse::<i64>() {
183        Ok(value)
184    } else {
185        Err(minijinja::Error::new(
186            minijinja::ErrorKind::UndefinedError,
187            "Unable to convert value in func_int",
188        ))
189    }
190}
191
192fn func_length(
193    c: minijinja::value::ViaDeserialize<serde_json::Value>,
194) -> Result<usize, minijinja::Error> {
195    if let Some(r) = c.as_array() {
196        Ok(r.len())
197    } else {
198        Ok(0)
199    }
200}
201
202fn func_list_to_obj(
203    list: minijinja::value::ViaDeserialize<serde_json::Value>,
204    attr_name: &str,
205) -> Result<minijinja::value::Value, minijinja::Error> {
206    let mut map = serde_json::json!({});
207    if let Some(list) = list.as_array() {
208        for item in list.iter() {
209            if let Some(key) = item.as_object().unwrap().get(attr_name) {
210                if let Some(key_str) = key.as_str() {
211                    let m = &mut map.as_object_mut().unwrap();
212                    if !m.contains_key(key_str) {
213                        m.insert(key_str.to_string(), serde_json::json!([]));
214                    }
215                    m[key_str].as_array_mut().unwrap().push(item.clone());
216                }
217            }
218        }
219    }
220    Ok(minijinja::value::Value::from_serialize(map))
221}
222
223fn func_count_identical(list: Vec<String>) -> HashMap<String, i32> {
224    let mut counts = HashMap::new();
225    for item in list {
226        *counts.entry(item).or_insert(0) += 1;
227    }
228    counts
229}
230
231fn func_trim(
232    _c: minijinja::value::ViaDeserialize<serde_json::Value>,
233) -> Result<String, minijinja::Error> {
234    if let Some(value) = _c.as_str() {
235        return Ok(clean_string(value.to_string()));
236    }
237    Err(minijinja::Error::new(
238        minijinja::ErrorKind::UndefinedError,
239        "Function trim did not get a string",
240    ))
241}
242
243fn func_sortby(
244    list: minijinja::value::ViaDeserialize<serde_json::Value>,
245    attr_to_sortby: &str,
246) -> Result<minijinja::value::Value, minijinja::Error> {
247    let mut ret = serde_json::json!([]);
248    if let Some(list) = list.as_array() {
249        let mut list_to_sort = list.clone();
250        list_to_sort.sort_by(|a, b| {
251            let a_value = a.get(attr_to_sortby).and_then(|v| v.as_str()).unwrap_or("");
252            let b_value = b.get(attr_to_sortby).and_then(|v| v.as_str()).unwrap_or("");
253            a_value.cmp(b_value)
254        });
255        ret = serde_json::Value::Array(list_to_sort.to_vec());
256    }
257    Ok(minijinja::value::Value::from_serialize(ret))
258}
259
260fn func_hex_coords(
261    _: minijinja::value::ViaDeserialize<serde_json::Value>,
262) -> Result<String, minijinja::Error> {
263    Ok(String::from("TBD"))
264}
265
266fn func_maybe(
267    v: minijinja::value::ViaDeserialize<serde_json::Value>,
268) -> Result<String, minijinja::Error> {
269    if let Some(s) = v.as_str() {
270        return Ok(s.to_string());
271    }
272    Ok(String::new())
273}
274
275fn func_sandbox(instance: &SandboxInstance) -> impl Fn() -> Result<String, minijinja::Error> {
276    let sid = match instance.sid.as_ref() {
277        Some(sid) => sid.clone(),
278        None => "".to_string(),
279    };
280    move || -> Result<String, minijinja::Error> { Ok(format!("/inspect/{}", sid)) }
281}
282
283fn func_round(value: f32, _dec: f32) -> Result<f32, minijinja::Error> {
284    let y = (value * 100.0).round() / 100.0;
285    if false {
286        return Err(minijinja::Error::new(
287            minijinja::ErrorKind::UndefinedError,
288            "",
289        ));
290    }
291    Ok(y)
292}
293
294fn func_max(a: i32, b: i32) -> Result<i32, minijinja::Error> {
295    if false {
296        return Err(minijinja::Error::new(
297            minijinja::ErrorKind::UndefinedError,
298            "",
299        ));
300    }
301    Ok(max(a, b))
302}
303
304fn func_appender(parent_uid: &str, attr: &str, cls: &str) -> String {
305    format!(
306        r#"
307        <a href="/append/{parent_uid}/{attr}/{cls}">⊞</a>
308        "#
309    )
310}
311
312fn func_plural(count: f32, v: &str) -> Result<String, minijinja::Error> {
313    if count <= 1.0 {
314        return Ok(v.to_string());
315    }
316    let mut plural = v.to_string();
317    let c = v.chars().last().unwrap_or('\0');
318    let c_minus_1 = v.chars().rev().nth(1).unwrap_or('\0');
319
320    if "sxzh".contains(c) {
321        plural.push_str("es");
322    } else if c == 'y' {
323        if "aeiou".contains(c_minus_1) {
324            plural.push('s');
325        } else {
326            plural.pop();
327            plural.push_str("ies");
328        }
329    } else if v.ends_with("olf") {
330        plural.pop();
331        plural.push_str("ves");
332    } else {
333        plural.push('s');
334    }
335    Ok(plural)
336}
337
338fn func_plural_with_count(count: f32, v: &str) -> Result<String, minijinja::Error> {
339    if count <= 1.0 {
340        return Ok(v.to_string());
341    }
342    Ok(format!(
343        "{} {}",
344        count as i32,
345        func_plural(count, v).unwrap()
346    ))
347}
348
349fn func_if_plural_else(
350    check: &str,
351    ifplural: &str,
352    ifnotplural: &str,
353) -> Result<String, minijinja::Error> {
354    let check = check.to_lowercase();
355    if check.ends_with('s') || check == "teeth" || check == "wolves" {
356        Ok(ifplural.to_string())
357    } else {
358        Ok(ifnotplural.to_string())
359    }
360}
361
362fn func_sum(l: minijinja::value::ViaDeserialize<serde_json::Value>) -> f64 {
363    let mut sum = 0.0;
364    for v in l.as_array().unwrap() {
365        if let Ok(a) = v.as_str().unwrap().parse::<f64>() {
366            sum += a;
367        }
368    }
369    sum
370}
371
372fn func_unique(
373    v: minijinja::value::ViaDeserialize<serde_json::Value>,
374    attr: minijinja::value::ViaDeserialize<serde_json::Value>,
375) -> Result<minijinja::value::Value, minijinja::Error> {
376    let mut ret = serde_json::json!([]);
377    let mut unique_set = HashSet::new();
378
379    if let Some(v) = v.as_array() {
380        for e in v.iter() {
381            if let Some(value) = e.as_object().unwrap().get(attr.as_str().unwrap()) {
382                if !unique_set.contains(value) {
383                    ret.as_array_mut().unwrap().push(e.clone());
384                    unique_set.insert(value.clone());
385                }
386            }
387        }
388        return Ok(minijinja::value::Value::from_serialize(&ret));
389    }
390    Ok(minijinja::value::Value::from_serialize(serde_json::json!(
391        {}
392    )))
393}
394
395fn func_stable_dice(roll: &str, uid: &str, index: u64) -> Result<i32, minijinja::Error> {
396    if false {
397        return Err(minijinja::Error::new(
398            minijinja::ErrorKind::UndefinedError,
399            "",
400        ));
401    }
402    let roller = caith::Roller::new(roll).unwrap();
403    let seed = string_to_seed(uid) + index;
404    let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(seed);
405    if let RollResultType::Single(value) = roller.roll_with(&mut rng).unwrap().get_result() {
406        return Ok(value.get_total() as i32);
407    }
408    Ok(0)
409}
410
411fn func_html_link(
412    instance: &SandboxInstance,
413) -> impl Fn(&str, &str) -> Result<String, minijinja::Error> {
414    let sid = match instance.sid.as_ref() {
415        Some(sid) => sid.clone(),
416        None => "".to_string(),
417    };
418    move |uid, text| -> Result<String, minijinja::Error> {
419        Ok(format!(
420            "<a href='/inspect/{}/entity/{}'>{}</a>",
421            sid, uid, text
422        ))
423    }
424}
425
426fn func_nop_0() -> Result<String, minijinja::Error> {
427    Ok(String::new())
428}
429
430fn func_nop_1(
431    _: minijinja::value::ViaDeserialize<serde_json::Value>,
432) -> Result<String, minijinja::Error> {
433    Ok(String::new())
434}
435
436fn func_reroll(
437    uid: minijinja::value::ViaDeserialize<serde_json::Value>,
438    _: minijinja::value::ViaDeserialize<serde_json::Value>,
439    _: minijinja::value::ViaDeserialize<serde_json::Value>,
440) -> Result<String, minijinja::Error> {
441    // "<a href='/reroll/{}'>⬣</a>",
442    let id = if let Some(obj) = uid.get("uuid") {
443        obj.to_string().trim_matches('"').to_string()
444    } else {
445        uid.to_string().trim_matches('"').to_string()
446    };
447    Ok(format!(
448        "<a href='/reroll/{}'>⟳</a><a href='/unroll/{}'>🗑</a>",
449        id, id
450    )
451    .to_string())
452}
453
454fn clean_string(mut s: String) -> String {
455    s = s.trim().to_string();
456    s.retain(|c| c != '\n' && c != '\r');
457    s = s
458        .chars()
459        .fold((String::new(), None), |(mut acc, prev_char), c| {
460            if c == ' ' && prev_char == Some(' ') {
461                (acc, prev_char)
462            } else {
463                acc.push(c);
464                (acc, Some(c))
465            }
466        })
467        .0;
468    s
469}
470
471fn format_with_commas(v: i64) -> String {
472    let s = v.to_string();
473    let mut formatted = String::new();
474    let mut count = 0;
475
476    for c in s.chars().rev() {
477        if count == 3 {
478            formatted.push(',');
479            count = 0;
480        }
481        formatted.push(c);
482        count += 1;
483    }
484    formatted.chars().rev().collect()
485}
486
487fn string_to_seed<S: AsRef<str>>(seed_str: S) -> u64 {
488    let mut hasher = std::hash::DefaultHasher::new();
489    std::hash::Hash::hash(&seed_str.as_ref(), &mut hasher);
490    std::hash::Hasher::finish(&hasher)
491}
492
493#[cfg(test)]
494mod tests {}