1use loom_math::Pcg32;
30use loom_ruleset::{
31 apply_triggered_mutations_with_rng, compare_ids, evaluate_action_with_rng, validate_check,
32 validate_triggered_mutations, AppliedMutation,
33};
34use serde_json::{json, Map, Value};
35use sha2::{Digest, Sha256};
36
37pub const DEFAULT_ACTOR_TAG: &str = "acts_offline";
39
40const REASON_UNKNOWN_ACTION: &str = "unknown_action";
43const REASON_INVALID_ACTION: &str = "invalid_action";
44const REASON_EVAL_ERROR: &str = "eval_error";
45const REASON_MALFORMED_PROPOSAL: &str = "malformed_proposal";
46
47pub const MAX_SAFE_INT: i64 = 9007199254740991;
51
52pub fn is_safe_epoch(n: i64) -> bool {
54 n >= -MAX_SAFE_INT && n <= MAX_SAFE_INT
55}
56
57pub fn derive_epoch_prng(world_id: &str, epoch_number: i64) -> Result<Pcg32, String> {
68 if !unicode_normalization::is_nfc(world_id) {
74 return Err("world-epoch: non-NFC world_id (normalize to NFC first)".to_string());
75 }
76 let id_bytes = world_id.as_bytes();
77 let epoch_bytes = epoch_number.to_le_bytes(); let mut hasher = Sha256::new();
79 hasher.update(id_bytes);
80 hasher.update(epoch_bytes);
81 let digest = hasher.finalize(); let mut state_b = [0u8; 8];
84 state_b.copy_from_slice(&digest[0..8]);
85 let mut inc_b = [0u8; 8];
86 inc_b.copy_from_slice(&digest[8..16]);
87 let state = u64::from_le_bytes(state_b);
88 let inc = u64::from_le_bytes(inc_b) | 1;
89 Ok(Pcg32::from_raw(state, inc))
90}
91
92enum ActionKind<'a> {
101 Check(Value),
102 Mutations(&'a Value),
103}
104
105fn classify_action(action: &Value) -> Result<ActionKind<'_>, String> {
106 match action.get("kind").and_then(|k| k.as_str()) {
107 Some("check") => {
108 let check = action.get("check").ok_or("world-epoch: check action missing check")?;
112 Ok(ActionKind::Check(check.clone()))
113 }
114 Some("mutations") => {
115 let m = action.get("mutations").ok_or("world-epoch: mutations action missing mutations")?;
116 Ok(ActionKind::Mutations(m))
117 }
118 _ => Err("world-epoch: action has unknown kind".to_string()),
119 }
120}
121
122fn serialize_mutation(m: &AppliedMutation) -> Value {
128 let mut o = Map::new();
129 o.insert("op".to_string(), Value::from(m.op.clone()));
130 o.insert("target".to_string(), Value::from(m.target.clone()));
131 if let Some(ref p) = m.property {
132 o.insert("property".to_string(), Value::from(p.clone()));
133 }
134 if let Some(ref t) = m.tag {
135 o.insert("tag".to_string(), Value::from(t.clone()));
136 }
137 if let Some(prev) = m.previous {
138 o.insert("previous".to_string(), Value::from(prev));
139 }
140 if let Some(next) = m.next {
141 o.insert("next".to_string(), Value::from(next));
142 }
143 Value::Object(o)
144}
145
146fn serialize_mutations(applied: &[AppliedMutation]) -> Value {
147 Value::Array(applied.iter().map(serialize_mutation).collect())
148}
149
150fn with_epoch(state: &Value, epoch_number: i64) -> Value {
152 let mut out = match state.as_object() {
153 Some(m) => m.clone(),
154 None => Map::new(),
155 };
156 out.insert("epoch".to_string(), Value::from(epoch_number));
157 Value::Object(out)
158}
159
160fn entity_has_actor_tag(tags: &Value, actor_tags: &[String]) -> bool {
161 let arr = match tags.as_array() {
162 Some(a) => a,
163 None => return false,
164 };
165 for t in arr {
166 if let Some(s) = t.as_str() {
167 if actor_tags.iter().any(|a| a == s) {
168 return true;
169 }
170 }
171 }
172 false
173}
174
175pub struct TickEpochInput<'a> {
178 pub world_id: &'a str,
179 pub state: &'a Value,
180 pub epoch_number: i64,
181 pub proposals: &'a Value,
183 pub ruleset: &'a Value,
185 pub actor_tags: Vec<String>,
187 pub max_actions: Option<u64>,
189}
190
191pub struct TickEpochResult {
192 pub state: Value,
193 pub event: Value,
195 pub resolved: u64,
196 pub rejected: u64,
197}
198
199pub fn tick_epoch(input: TickEpochInput) -> Result<TickEpochResult, String> {
204 let actor_tags: Vec<String> = if input.actor_tags.is_empty() {
205 vec![DEFAULT_ACTOR_TAG.to_string()]
206 } else {
207 input.actor_tags.clone()
208 };
209 let max_actions = input.max_actions.unwrap_or(u64::MAX);
210
211 let mut prng = derive_epoch_prng(input.world_id, input.epoch_number)?;
212
213 let mut actors: Vec<String> = Vec::new();
216 if let Some(entities) = input.state.get("entities").and_then(|e| e.as_object()) {
217 for (id, ent) in entities {
218 let tags = ent.get("tags").cloned().unwrap_or(Value::Null);
219 if entity_has_actor_tag(&tags, &actor_tags) {
220 actors.push(id.clone());
221 }
222 }
223 }
224 actors.sort_by(|a, b| compare_ids(a, b));
225
226 let mut work = input.state.clone();
227 let mut entries: Vec<Value> = Vec::new();
228 let mut resolved: u64 = 0;
229 let mut rejected: u64 = 0;
230
231 for actor_id in &actors {
232 if resolved >= max_actions {
233 break; }
235 let proposal = match input.proposals.get(actor_id) {
236 Some(p) if p.is_object() => p,
237 _ => continue, };
239 let action_id = match proposal.get("actionId").and_then(|x| x.as_str()) {
240 Some(s) if !s.is_empty() => s.to_string(),
241 _ => {
242 entries.push(json!({ "action_id": "", "actor_id": actor_id, "reason": REASON_MALFORMED_PROPOSAL }));
246 rejected += 1;
247 continue;
248 }
249 };
250 let target_id = proposal.get("targetId").and_then(|x| x.as_str());
251
252 let action = match input.ruleset.get(&action_id) {
253 Some(a) => a,
254 None => {
255 entries.push(json!({
257 "action_id": action_id,
258 "actor_id": actor_id,
259 "reason": REASON_UNKNOWN_ACTION,
260 }));
261 rejected += 1;
262 continue;
263 }
264 };
265
266 let kind = match classify_action(action) {
267 Ok(k) => k,
268 Err(_) => {
269 entries.push(json!({
270 "action_id": action_id,
271 "actor_id": actor_id,
272 "reason": REASON_INVALID_ACTION,
273 }));
274 rejected += 1;
275 continue;
276 }
277 };
278
279 let valid = match &kind {
281 ActionKind::Check(node) => validate_check(node),
282 ActionKind::Mutations(muts) => validate_triggered_mutations(muts),
283 };
284 if valid.is_err() {
285 entries.push(json!({
286 "action_id": action_id,
287 "actor_id": actor_id,
288 "reason": REASON_INVALID_ACTION,
289 }));
290 rejected += 1;
291 continue;
292 }
293
294 let snap = prng.snapshot();
297 let outcome: Result<(String, Vec<AppliedMutation>, Value), String> = match &kind {
298 ActionKind::Check(node) => {
299 evaluate_action_with_rng(&work, node, actor_id, target_id, &mut prng)
300 .map(|r| (r.degree, r.mutations, r.state))
301 }
302 ActionKind::Mutations(muts) => {
303 apply_triggered_mutations_with_rng(&work, muts, actor_id, target_id, &mut prng)
304 .map(|r| ("none".to_string(), r.mutations, r.state))
305 }
306 };
307
308 match outcome {
309 Ok((degree, applied, new_state)) => {
310 work = new_state;
311 entries.push(json!({
312 "action_id": action_id,
313 "actor_id": actor_id,
314 "degree": degree,
315 "mutations_applied": serialize_mutations(&applied),
316 }));
317 resolved += 1;
318 }
319 Err(_) => {
320 prng.restore(snap); entries.push(json!({
322 "action_id": action_id,
323 "actor_id": actor_id,
324 "reason": REASON_EVAL_ERROR,
325 }));
326 rejected += 1;
327 }
328 }
329 }
330
331 let out_state = with_epoch(&work, input.epoch_number);
332 let event = json!({
333 "event_type": "EpochResolved",
334 "epoch_number": input.epoch_number,
335 "actions_processed": Value::Array(entries),
336 "pcg_steps_consumed": prng.get_draws(),
337 });
338 Ok(TickEpochResult {
339 state: out_state,
340 event,
341 resolved,
342 rejected,
343 })
344}
345
346pub struct CatchUpInput<'a> {
349 pub world_id: &'a str,
350 pub state: &'a Value,
351 pub current_epoch: i64,
353 pub max_catchup: i64,
355 pub ruleset: &'a Value,
356 pub proposals_by_epoch: &'a Value,
358 pub actor_tags: Vec<String>,
359 pub max_actions: Option<u64>,
360}
361
362pub struct CatchUpResult {
363 pub state: Value,
364 pub events: Vec<Value>,
365 pub epochs_resolved: i64,
366 pub epochs_voided: i64,
367}
368
369pub fn catch_up_epochs(input: CatchUpInput) -> Result<CatchUpResult, String> {
374 let client_epoch = input.state.get("epoch").and_then(|e| e.as_i64()).unwrap_or(0);
375 let target = input.current_epoch.checked_sub(client_epoch).unwrap_or(0);
378 if target <= 0 {
379 derive_epoch_prng(input.world_id, 0)?;
382 return Ok(CatchUpResult {
383 state: input.state.clone(),
384 events: Vec::new(),
385 epochs_resolved: 0,
386 epochs_voided: 0,
387 });
388 }
389 let capped = if target > input.max_catchup { input.max_catchup.max(0) } else { target };
392
393 let mut work = input.state.clone();
394 let mut events: Vec<Value> = Vec::new();
395 let empty = json!({});
396 let mut i = 1;
397 while i <= capped {
398 let epoch_n = match client_epoch.checked_add(i) {
399 Some(e) => e,
400 None => break, };
402 let proposals = input
403 .proposals_by_epoch
404 .get(epoch_n.to_string())
405 .filter(|p| p.is_object())
406 .unwrap_or(&empty);
407 let r = tick_epoch(TickEpochInput {
408 world_id: input.world_id,
409 state: &work,
410 epoch_number: epoch_n,
411 proposals,
412 ruleset: input.ruleset,
413 actor_tags: input.actor_tags.clone(),
414 max_actions: input.max_actions,
415 })?;
416 work = r.state;
417 events.push(r.event);
418 i += 1;
419 }
420
421 Ok(CatchUpResult {
422 state: work,
423 events,
424 epochs_resolved: capped,
425 epochs_voided: target - capped,
426 })
427}
428
429fn parse_actor_tags(v: &Value) -> Vec<String> {
432 v.get("actorTags")
433 .and_then(|t| t.as_array())
434 .map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
435 .unwrap_or_default()
436}
437
438fn parse_max_actions(v: &Value) -> Result<Option<u64>, String> {
441 match v.get("maxActions") {
442 None | Some(Value::Null) => Ok(None),
443 Some(m) => {
444 let n = m
445 .as_u64()
446 .filter(|n| *n <= MAX_SAFE_INT as u64)
447 .ok_or("world-epoch: maxActions must be a non-negative JS-safe integer")?;
448 Ok(Some(n))
449 }
450 }
451}
452
453pub fn tick_epoch_from_json(input_json: &str) -> Result<String, String> {
458 let v: Value = serde_json::from_str(input_json).map_err(|e| format!("world-epoch: bad tick input json: {}", e))?;
459 let world_id = v.get("worldId").and_then(|x| x.as_str()).ok_or("world-epoch: worldId must be a string")?;
460 let epoch_number = v.get("epochNumber").and_then(|x| x.as_i64()).ok_or("world-epoch: epochNumber must be an integer")?;
461 if !is_safe_epoch(epoch_number) {
462 return Err("world-epoch: epoch_number must be a JS-safe integer".to_string());
463 }
464 let max_actions = parse_max_actions(&v)?;
465 let r = tick_epoch(TickEpochInput {
466 world_id,
467 state: &v["state"],
468 epoch_number,
469 proposals: &v["proposals"],
470 ruleset: &v["ruleset"],
471 actor_tags: parse_actor_tags(&v),
472 max_actions,
473 })?;
474 let out = json!({ "state": r.state, "event": r.event, "resolved": r.resolved, "rejected": r.rejected });
475 serde_json::to_string(&out).map_err(|e| format!("world-epoch: serialize: {}", e))
476}
477
478pub fn catch_up_epochs_from_json(input_json: &str) -> Result<String, String> {
482 let v: Value = serde_json::from_str(input_json).map_err(|e| format!("world-epoch: bad catchup input json: {}", e))?;
483 let world_id = v.get("worldId").and_then(|x| x.as_str()).ok_or("world-epoch: worldId must be a string")?;
484 let current_epoch = v.get("currentEpoch").and_then(|x| x.as_i64()).ok_or("world-epoch: currentEpoch must be an integer")?;
485 if !is_safe_epoch(current_epoch) {
486 return Err("world-epoch: currentEpoch must be a JS-safe integer".to_string());
487 }
488 let max_catchup = v.get("maxCatchup").and_then(|x| x.as_i64()).ok_or("world-epoch: maxCatchup must be an integer")?;
489 if max_catchup < 0 || !is_safe_epoch(max_catchup) {
490 return Err("world-epoch: maxCatchup must be a non-negative JS-safe integer".to_string());
491 }
492 let client_epoch = v.get("state").and_then(|s| s.get("epoch")).and_then(|e| e.as_i64()).unwrap_or(0);
495 if !is_safe_epoch(client_epoch) {
496 return Err("world-epoch: state.epoch must be a JS-safe integer".to_string());
497 }
498 let max_actions = parse_max_actions(&v)?;
499 let r = catch_up_epochs(CatchUpInput {
500 world_id,
501 state: &v["state"],
502 current_epoch,
503 max_catchup,
504 ruleset: &v["ruleset"],
505 proposals_by_epoch: &v["proposalsByEpoch"],
506 actor_tags: parse_actor_tags(&v),
507 max_actions,
508 })?;
509 let out = json!({ "state": r.state, "events": r.events, "epochsResolved": r.epochs_resolved, "epochsVoided": r.epochs_voided });
510 serde_json::to_string(&out).map_err(|e| format!("world-epoch: serialize: {}", e))
511}
512
513pub const RESOURCE_WORLD_EPOCH: &str = "world_epoch";