Skip to main content

loom_epoch/
lib.rs

1//! loom_epoch - the deterministic between-session Epoch world-tick (Rust core).
2//!
3//! v3.0 Phase 3 (Living Persistent World). The native sibling of the TS
4//! `src/runtime/world-epoch.ts`. While a player is offline the world must keep
5//! moving - factions act, regions shift - WITHOUT the session/combat PRNG and
6//! WITHOUT any non-determinism, so the browser client and the authoritative
7//! server arrive at the BYTE-IDENTICAL world-state hash for the same epoch.
8//!
9//! THE THREE GUARANTEES (all cross-language byte-parity, pinned by
10//! test_vectors/v3_3_epoch_tick.json):
11//!
12//!   1. PRNG ISOLATION. The Epoch PRNG is seeded from SHA-256(UTF8(world_id) ||
13//!      LE64(epoch_number)) - a fresh, PUBLIC derivation that never touches the
14//!      session PRNG. digest[0..8] LE -> state, digest[8..16] LE |1 -> inc, built
15//!      straight into `Pcg32::from_raw` with NO seeding steps.
16//!
17//!   2. DETERMINISTIC ORDER + FAIL-CLOSED RESOLUTION. Offline actors are the
18//!      entities carrying an actor tag; they resolve in `compare_ids` order. A
19//!      proposal naming an unknown action, or failing AST validation, or erroring
20//!      mid-eval is REJECTED and consumes ZERO prng + ZERO state change (prng
21//!      snapshot/restore + the AST's clone-not-mutate contract). Reason codes are
22//!      assigned HERE at fixed decision points - never parsed from error text -
23//!      so they are identical on every surface.
24//!
25//!   3. BOUNDED COST. `tick_epoch` caps SUCCESSFUL resolutions at `max_actions`;
26//!      `catch_up_epochs` caps replayed epochs at `max_catchup`. Both are
27//!      PARAMETERS, never hardcoded.
28
29use 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
37/// The default tag marking an entity that acts while the owner is offline.
38pub const DEFAULT_ACTOR_TAG: &str = "acts_offline";
39
40// Fixed reason vocabulary - assigned by THIS code (never from error text), so
41// every surface emits the same string for the same input.
42const 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
47/// The JS-safe integer bound (2^53 - 1). Epoch / catch-up / cap inputs beyond this
48/// are rejected at the JSON boundary, matching the TS/Python guards (and keeping the
49/// emitted event JSON hashable). Codex P1.
50pub const MAX_SAFE_INT: i64 = 9007199254740991;
51
52/// True iff `n` is a JS-safe integer epoch (|n| <= 2^53 - 1).
53pub fn is_safe_epoch(n: i64) -> bool {
54    n >= -MAX_SAFE_INT && n <= MAX_SAFE_INT
55}
56
57// ---- Epoch PRNG derivation -------------------------------------------------
58
59/// Derive the Epoch PRNG for `(world_id, epoch_number)`. PUBLIC + deterministic:
60/// any surface computes the same PRNG from these two inputs.
61///
62///   msg    = utf8(world_id) || i64_le(epoch_number)   (8 bytes, two's complement)
63///   digest = SHA-256(msg)
64///   state  = u64 from digest[0..8]  little-endian
65///   inc    = u64 from digest[8..16] little-endian, |1 (forced odd)
66///   prng   = Pcg32::from_raw(state, inc)
67pub fn derive_epoch_prng(world_id: &str, epoch_number: i64) -> Result<Pcg32, String> {
68    // Round-6 audit HIGH: TS deriveEpochPrng (assertCleanString) and Python
69    // derive_epoch_prng (_assert_clean_string) reject a non-NFC world_id;
70    // Rust hashed the decomposed bytes and derived a DIFFERENT seed - a
71    // cross-surface determinism fork. Reject identically (a Rust &str cannot
72    // hold a lone surrogate, so NFC is the only check needed).
73    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(); // i64 LE, two's complement for negatives
78    let mut hasher = Sha256::new();
79    hasher.update(id_bytes);
80    hasher.update(epoch_bytes);
81    let digest = hasher.finalize(); // 32 bytes
82
83    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
92// ---- Action AST kinds ------------------------------------------------------
93
94// A WorldAction is a JSON object: { "kind": "check", "check"|inline check fields }
95// or { "kind": "mutations", "mutations": [...] }. The TS vector stores a check
96// action with the check fields (roll/dc/degrees) INLINE alongside "kind", and a
97// mutations action with a "mutations" array. We read the kind, then build the
98// AST shape the loom_ruleset AST expects.
99
100enum 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            // A check action nests its CheckNode under "check" (the TS WorldAction
109            // shape { kind:"check", check: CheckNode }). Read it nested - NOT inline -
110            // so validate_check / evaluate_action see the same node TS does.
111            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
122// ---- Helpers ---------------------------------------------------------------
123
124/// Serialize an AppliedMutation as a canonical JSON object with ONLY the present
125/// fields (omit absent ones; never emit nulls) - mirrors the TS
126/// `serializeMutations`, so canonical_json encodes the same key set everywhere.
127fn 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
150// Shallow top-level clone of the state with epoch replaced (never mutates input).
151fn 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
175// ---- tick_epoch ------------------------------------------------------------
176
177pub struct TickEpochInput<'a> {
178    pub world_id: &'a str,
179    pub state: &'a Value,
180    pub epoch_number: i64,
181    /// actor_id -> proposal { "actionId": str, optional "targetId": str }.
182    pub proposals: &'a Value,
183    /// action_id -> WorldAction. Caller-owned content.
184    pub ruleset: &'a Value,
185    /// Tags marking offline actors. Empty -> [DEFAULT_ACTOR_TAG].
186    pub actor_tags: Vec<String>,
187    /// Cap on SUCCESSFUL resolutions (Veil-Ceiling guard). None -> no cap.
188    pub max_actions: Option<u64>,
189}
190
191pub struct TickEpochResult {
192    pub state: Value,
193    /// The canonical EpochResolved event as a JSON Value (hashable identically to TS).
194    pub event: Value,
195    pub resolved: u64,
196    pub rejected: u64,
197}
198
199/// Resolve one offline epoch. Pure: does not mutate `input.state`. Returns the
200/// new state (epoch advanced) + the canonical EpochResolved event.
201/// Errs (never panics) on a non-NFC world_id - the same inputs TS/Python throw
202/// on (round-6 audit HIGH).
203pub 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    // Identify offline actors, then sort by the numeric-aware id comparator so the
214    // resolution (and PRNG draw) order is byte-identical everywhere.
215    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; // Veil-Ceiling guard - stop after the cap
234        }
235        let proposal = match input.proposals.get(actor_id) {
236            Some(p) if p.is_object() => p,
237            _ => continue, // no proposal -> the actor idles (not counted, not listed)
238        };
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                // malformed proposal (missing / non-string / empty actionId) - a fixed
243                // schema rejection + zero prng (matches TS/Python), NOT a silent idle
244                // and NOT a crash (Codex P1).
245                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                // (1) unknown action - no prng, no state change.
256                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        // (2) fail-closed validation BEFORE any prng draw. Reason assigned here.
280        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        // (3) resolve. Snapshot prng first; on ANY error roll it back to zero draws
295        // (the AST clones state, so a failed resolve never mutated `work`).
296        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); // zero prng consumed for a rejected proposal
321                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
346// ---- catch_up_epochs -------------------------------------------------------
347
348pub struct CatchUpInput<'a> {
349    pub world_id: &'a str,
350    pub state: &'a Value,
351    /// The current epoch from the caller's clock (read OUTSIDE the engine).
352    pub current_epoch: i64,
353    /// Bound on epochs replayed per reconnect (caller-supplied).
354    pub max_catchup: i64,
355    pub ruleset: &'a Value,
356    /// Optional per-epoch proposals keyed by str(epoch_number). Missing -> {}.
357    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
369/// Deterministically replay offline epochs from `state.epoch` up to
370/// `current_epoch`, capped at `max_catchup`. Result depends only on
371/// (state, capped, proposals) - never on the wall clock directly.
372/// Errs (never panics) on a non-NFC world_id (round-6 audit HIGH).
373pub 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    // Codex P1: checked arithmetic - a hostile state.epoch (e.g. i64::MIN) must not
376    // overflow/panic; an un-subtractable epoch yields no catch-up.
377    let target = input.current_epoch.checked_sub(client_epoch).unwrap_or(0);
378    if target <= 0 {
379        // Even the no-op path validates world_id, so a non-NFC id is rejected
380        // identically regardless of whether any epochs need replaying.
381        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    // Defense-in-depth: clamp a negative max_catchup to 0 (the JSON boundary already
390    // rejects it; a direct caller gets "no catch-up" instead of garbage counts). Codex P1.
391    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, // epoch counter would overflow i64 - stop, deterministically
401        };
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
429// ---- validating JSON boundary (the WASM + PyO3 surfaces call THESE) ---------
430
431fn 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
438// maxActions: absent/null -> no cap; present -> MUST be a non-negative JS-safe integer
439// (as_u64 already rejects negatives + fractions; we additionally bound it). Codex P1.
440fn 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
453/// JSON-in / JSON-out tick_epoch WITH full input validation - the boundary the WASM
454/// + PyO3 surfaces call, so every surface rejects the same epoch / maxActions inputs
455/// TS + Python reject. Input: {worldId, state, epochNumber, proposals, ruleset,
456/// actorTags?, maxActions?}. Returns {state, event, resolved, rejected}.
457pub 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
478/// JSON-in / JSON-out catch_up_epochs WITH full input validation. Input: {worldId,
479/// state, currentEpoch, maxCatchup, ruleset, proposalsByEpoch?, actorTags?,
480/// maxActions?}. Returns {state, events, epochsResolved, epochsVoided}.
481pub 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    // Codex P1: a state.epoch outside the JS-safe range is rejected at the boundary
493    // (it could never round-trip through the canonical hash anyway).
494    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
513/// Resource key for the world's resource registry (matches the TS constant).
514pub const RESOURCE_WORLD_EPOCH: &str = "world_epoch";