verticopolis_engine/gameplay/
check.rs1use serde_json::Value;
4
5fn 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
20fn 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
27const RESERVED_SETS: [&str; 5] = [
30 "catalogVersion",
31 "event",
32 "eventName",
33 "eventPayloads",
34 "eventVersions",
35];
36
37fn 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
43fn prose(v: Option<&Value>) -> bool {
46 v.and_then(Value::as_str)
47 .is_some_and(|s| !s.is_empty() && !s.contains("*/"))
48}
49
50pub 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
173pub 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}