Skip to main content
Verticopolis engineGitHub

verticopolis_engine/gameplay/
check.rs

1// Checking the gameplay event catalog and the events drained against it.
2
3use serde_json::Value;
4
5/// A JSON number that is a whole number JavaScript holds exactly, as
6/// `Number.isSafeInteger` reads it: `3` and `3.0` alike (the parsed text
7/// cannot tell them apart in TypeScript, so neither side may either), and
8/// nothing past 2^53 - 1 in magnitude.
9fn whole(v: &Value) -> Option<i64> {
10    const SAFE: i64 = 9_007_199_254_740_991;
11    v.as_i64()
12        .or_else(|| {
13            v.as_f64()
14                .filter(|f| f.fract() == 0.0 && f.abs() <= SAFE as f64)
15                .map(|f| f as i64)
16        })
17        .filter(|n| n.unsigned_abs() <= SAFE as u64)
18}
19
20/// `snake_case` from a lowercase letter: an event name.
21fn is_event_name(s: &str) -> bool {
22    s.starts_with(|c: char| c.is_ascii_lowercase())
23        && s.chars()
24            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
25}
26
27/// Enum set names whose generated type would collide with one the
28/// declaration already exports.
29const RESERVED_SETS: [&str; 5] = [
30    "catalogVersion",
31    "event",
32    "eventName",
33    "eventPayloads",
34    "eventVersions",
35];
36
37/// `camelCase` from a lowercase letter: an enum set or a payload field, both
38/// of which become TypeScript identifiers in the generated declaration.
39fn is_camel_name(s: &str) -> bool {
40    s.starts_with(|c: char| c.is_ascii_lowercase()) && s.chars().all(|c| c.is_ascii_alphanumeric())
41}
42
43/// Prose the generated declaration carries in a doc comment: non-empty and
44/// unable to close that comment early.
45fn prose(v: Option<&Value>) -> bool {
46    v.and_then(Value::as_str)
47        .is_some_and(|s| !s.is_empty() && !s.contains("*/"))
48}
49
50/// Checks a catalog's shape and its payload rule: every field is an enum the
51/// catalog defines or a bounded integer, so no free text, money or name can
52/// enter an event.
53pub fn check_catalog(catalog: &Value) -> Result<(), String> {
54    if !catalog.is_object() {
55        return Err("catalog: must be an object".into());
56    }
57    if !catalog
58        .get("catalogVersion")
59        .and_then(whole)
60        .is_some_and(|v| v >= 1)
61    {
62        return Err("catalog: catalogVersion must be a whole number from 1".into());
63    }
64    if !prose(catalog.get("about")) {
65        return Err("catalog: about must be a non-empty string without */".into());
66    }
67    let enums = catalog
68        .get("enums")
69        .and_then(Value::as_object)
70        .ok_or("catalog: enums must be an object")?;
71    for (name, values) in enums {
72        if !is_camel_name(name) {
73            return Err(format!("enum {name}: name must be camelCase"));
74        }
75        if RESERVED_SETS.contains(&name.as_str()) {
76            return Err(format!(
77                "enum {name}: name is reserved by the generated declaration"
78            ));
79        }
80        let values = values
81            .as_array()
82            .filter(|v| !v.is_empty())
83            .ok_or_else(|| format!("enum {name}: must be a non-empty array"))?;
84        let mut seen = std::collections::HashSet::new();
85        for v in values {
86            let s = v
87                .as_str()
88                .filter(|s| !s.is_empty())
89                .ok_or_else(|| format!("enum {name}: values must be non-empty strings"))?;
90            if !seen.insert(s) {
91                return Err(format!("enum {name}: {s} is listed twice"));
92            }
93        }
94    }
95    let events = catalog
96        .get("events")
97        .and_then(Value::as_array)
98        .ok_or("catalog: events must be an array")?;
99    let mut names = std::collections::HashSet::new();
100    for e in events {
101        let name = e
102            .get("name")
103            .and_then(Value::as_str)
104            .filter(|s| is_event_name(s))
105            .ok_or("event: name must be snake_case")?;
106        if !names.insert(name) {
107            return Err(format!("event {name}: listed twice"));
108        }
109        if !e.get("version").and_then(whole).is_some_and(|v| v >= 1) {
110            return Err(format!(
111                "event {name}: version must be a whole number from 1"
112            ));
113        }
114        if !matches!(
115            e.get("cardinality").and_then(Value::as_str),
116            Some("per_occurrence") | Some("per_tower")
117        ) {
118            return Err(format!(
119                "event {name}: cardinality must be per_occurrence or per_tower"
120            ));
121        }
122        for key in ["semantics", "history"] {
123            if !prose(e.get(key)) {
124                return Err(format!(
125                    "event {name}: {key} must be a non-empty string without */"
126                ));
127            }
128        }
129        let payload = e
130            .get("payload")
131            .and_then(Value::as_object)
132            .ok_or_else(|| format!("event {name}: payload must be an object"))?;
133        for (field, spec) in payload {
134            let where_ = format!("event {name} field {field}");
135            if !is_camel_name(field) {
136                return Err(format!("{where_}: name must be camelCase"));
137            }
138            match spec.get("type").and_then(Value::as_str) {
139                Some("enum") => {
140                    let set = spec
141                        .get("enum")
142                        .and_then(Value::as_str)
143                        .ok_or_else(|| format!("{where_}: enum must name a catalog enum"))?;
144                    if !enums.contains_key(set) {
145                        return Err(format!("{where_}: enum {set} is not in the catalog"));
146                    }
147                }
148                Some("integer") => {
149                    let min = spec.get("min").and_then(whole);
150                    let max = spec.get("max").and_then(whole);
151                    match (min, max) {
152                        (Some(lo), Some(hi))
153                            if lo <= hi && lo.unsigned_abs() <= 100_000 && hi.unsigned_abs() <= 100_000 => {}
154                        _ => {
155                            return Err(format!(
156                                "{where_}: an integer needs whole min and max within 100000"
157                            ))
158                        }
159                    }
160                }
161                other => {
162                    return Err(format!(
163                        "{where_}: type {} is not allowed; payloads carry closed enums and small integers only",
164                        other.unwrap_or("(none)")
165                    ))
166                }
167            }
168        }
169    }
170    Ok(())
171}
172
173/// Checks one drained event (as [`GameplayEvent::to_json`](super::GameplayEvent::to_json) writes it) against
174/// a catalog: a known name, exactly the catalog's fields, each enum value
175/// listed and each integer in range.
176///
177/// ```
178/// use verticopolis_engine::clock::GameMode;
179/// use verticopolis_engine::gameplay::{check_event, GameplayEvent, CATALOG_JSON};
180///
181/// let catalog: serde_json::Value = serde_json::from_str(CATALOG_JSON).unwrap();
182/// let event = GameplayEvent::TowerFounded { mode: GameMode::Modern };
183/// assert!(check_event(&catalog, &event.to_json()).is_ok());
184/// ```
185pub fn check_event(catalog: &Value, event: &Value) -> Result<(), String> {
186    let name = event
187        .get("name")
188        .and_then(Value::as_str)
189        .ok_or("event: name must be a string")?;
190    let entry = catalog["events"]
191        .as_array()
192        .and_then(|a| a.iter().find(|e| e["name"] == name))
193        .ok_or_else(|| format!("event {name}: not in the catalog"))?;
194    let spec = entry["payload"]
195        .as_object()
196        .ok_or_else(|| format!("event {name}: the catalog entry has no payload"))?;
197    let payload = match (event.as_object().map(|o| o.len()), event.get("payload")) {
198        (Some(2), Some(Value::Object(p))) => p,
199        _ => return Err(format!("event {name}: must be exactly {{ name, payload }}")),
200    };
201    for k in payload.keys() {
202        if !spec.contains_key(k) {
203            return Err(format!("event {name}: field {k} is not in the catalog"));
204        }
205    }
206    for (field, s) in spec {
207        let v = payload
208            .get(field)
209            .ok_or_else(|| format!("event {name}: field {field} is missing"))?;
210        let ok = match s["type"].as_str() {
211            Some("enum") => {
212                let set = s["enum"].as_str().unwrap_or_default();
213                v.as_str().is_some_and(|x| {
214                    catalog["enums"][set]
215                        .as_array()
216                        .is_some_and(|vals| vals.iter().any(|y| y == x))
217                })
218            }
219            Some("integer") => whole(v).is_some_and(|n| {
220                whole(&s["min"]).is_some_and(|lo| n >= lo)
221                    && whole(&s["max"]).is_some_and(|hi| n <= hi)
222            }),
223            _ => false,
224        };
225        if !ok {
226            return Err(format!(
227                "event {name}: field {field} = {v} is outside the catalog"
228            ));
229        }
230    }
231    Ok(())
232}