use loom_math::Pcg32;
use loom_ruleset::{
apply_triggered_mutations_with_rng, compare_ids, evaluate_action_with_rng, validate_check,
validate_triggered_mutations, AppliedMutation,
};
use serde_json::{json, Map, Value};
use sha2::{Digest, Sha256};
pub const DEFAULT_ACTOR_TAG: &str = "acts_offline";
const REASON_UNKNOWN_ACTION: &str = "unknown_action";
const REASON_INVALID_ACTION: &str = "invalid_action";
const REASON_EVAL_ERROR: &str = "eval_error";
const REASON_MALFORMED_PROPOSAL: &str = "malformed_proposal";
pub const MAX_SAFE_INT: i64 = 9007199254740991;
pub fn is_safe_epoch(n: i64) -> bool {
n >= -MAX_SAFE_INT && n <= MAX_SAFE_INT
}
pub fn derive_epoch_prng(world_id: &str, epoch_number: i64) -> Result<Pcg32, String> {
if !unicode_normalization::is_nfc(world_id) {
return Err("world-epoch: non-NFC world_id (normalize to NFC first)".to_string());
}
let id_bytes = world_id.as_bytes();
let epoch_bytes = epoch_number.to_le_bytes(); let mut hasher = Sha256::new();
hasher.update(id_bytes);
hasher.update(epoch_bytes);
let digest = hasher.finalize();
let mut state_b = [0u8; 8];
state_b.copy_from_slice(&digest[0..8]);
let mut inc_b = [0u8; 8];
inc_b.copy_from_slice(&digest[8..16]);
let state = u64::from_le_bytes(state_b);
let inc = u64::from_le_bytes(inc_b) | 1;
Ok(Pcg32::from_raw(state, inc))
}
enum ActionKind<'a> {
Check(Value),
Mutations(&'a Value),
}
fn classify_action(action: &Value) -> Result<ActionKind<'_>, String> {
match action.get("kind").and_then(|k| k.as_str()) {
Some("check") => {
let check = action.get("check").ok_or("world-epoch: check action missing check")?;
Ok(ActionKind::Check(check.clone()))
}
Some("mutations") => {
let m = action.get("mutations").ok_or("world-epoch: mutations action missing mutations")?;
Ok(ActionKind::Mutations(m))
}
_ => Err("world-epoch: action has unknown kind".to_string()),
}
}
fn serialize_mutation(m: &AppliedMutation) -> Value {
let mut o = Map::new();
o.insert("op".to_string(), Value::from(m.op.clone()));
o.insert("target".to_string(), Value::from(m.target.clone()));
if let Some(ref p) = m.property {
o.insert("property".to_string(), Value::from(p.clone()));
}
if let Some(ref t) = m.tag {
o.insert("tag".to_string(), Value::from(t.clone()));
}
if let Some(prev) = m.previous {
o.insert("previous".to_string(), Value::from(prev));
}
if let Some(next) = m.next {
o.insert("next".to_string(), Value::from(next));
}
Value::Object(o)
}
fn serialize_mutations(applied: &[AppliedMutation]) -> Value {
Value::Array(applied.iter().map(serialize_mutation).collect())
}
fn with_epoch(state: &Value, epoch_number: i64) -> Value {
let mut out = match state.as_object() {
Some(m) => m.clone(),
None => Map::new(),
};
out.insert("epoch".to_string(), Value::from(epoch_number));
Value::Object(out)
}
fn entity_has_actor_tag(tags: &Value, actor_tags: &[String]) -> bool {
let arr = match tags.as_array() {
Some(a) => a,
None => return false,
};
for t in arr {
if let Some(s) = t.as_str() {
if actor_tags.iter().any(|a| a == s) {
return true;
}
}
}
false
}
pub struct TickEpochInput<'a> {
pub world_id: &'a str,
pub state: &'a Value,
pub epoch_number: i64,
pub proposals: &'a Value,
pub ruleset: &'a Value,
pub actor_tags: Vec<String>,
pub max_actions: Option<u64>,
}
pub struct TickEpochResult {
pub state: Value,
pub event: Value,
pub resolved: u64,
pub rejected: u64,
}
pub fn tick_epoch(input: TickEpochInput) -> Result<TickEpochResult, String> {
let actor_tags: Vec<String> = if input.actor_tags.is_empty() {
vec![DEFAULT_ACTOR_TAG.to_string()]
} else {
input.actor_tags.clone()
};
let max_actions = input.max_actions.unwrap_or(u64::MAX);
let mut prng = derive_epoch_prng(input.world_id, input.epoch_number)?;
let mut actors: Vec<String> = Vec::new();
if let Some(entities) = input.state.get("entities").and_then(|e| e.as_object()) {
for (id, ent) in entities {
let tags = ent.get("tags").cloned().unwrap_or(Value::Null);
if entity_has_actor_tag(&tags, &actor_tags) {
actors.push(id.clone());
}
}
}
actors.sort_by(|a, b| compare_ids(a, b));
let mut work = input.state.clone();
let mut entries: Vec<Value> = Vec::new();
let mut resolved: u64 = 0;
let mut rejected: u64 = 0;
for actor_id in &actors {
if resolved >= max_actions {
break; }
let proposal = match input.proposals.get(actor_id) {
Some(p) if p.is_object() => p,
_ => continue, };
let action_id = match proposal.get("actionId").and_then(|x| x.as_str()) {
Some(s) if !s.is_empty() => s.to_string(),
_ => {
entries.push(json!({ "action_id": "", "actor_id": actor_id, "reason": REASON_MALFORMED_PROPOSAL }));
rejected += 1;
continue;
}
};
let target_id = proposal.get("targetId").and_then(|x| x.as_str());
let action = match input.ruleset.get(&action_id) {
Some(a) => a,
None => {
entries.push(json!({
"action_id": action_id,
"actor_id": actor_id,
"reason": REASON_UNKNOWN_ACTION,
}));
rejected += 1;
continue;
}
};
let kind = match classify_action(action) {
Ok(k) => k,
Err(_) => {
entries.push(json!({
"action_id": action_id,
"actor_id": actor_id,
"reason": REASON_INVALID_ACTION,
}));
rejected += 1;
continue;
}
};
let valid = match &kind {
ActionKind::Check(node) => validate_check(node),
ActionKind::Mutations(muts) => validate_triggered_mutations(muts),
};
if valid.is_err() {
entries.push(json!({
"action_id": action_id,
"actor_id": actor_id,
"reason": REASON_INVALID_ACTION,
}));
rejected += 1;
continue;
}
let snap = prng.snapshot();
let outcome: Result<(String, Vec<AppliedMutation>, Value), String> = match &kind {
ActionKind::Check(node) => {
evaluate_action_with_rng(&work, node, actor_id, target_id, &mut prng)
.map(|r| (r.degree, r.mutations, r.state))
}
ActionKind::Mutations(muts) => {
apply_triggered_mutations_with_rng(&work, muts, actor_id, target_id, &mut prng)
.map(|r| ("none".to_string(), r.mutations, r.state))
}
};
match outcome {
Ok((degree, applied, new_state)) => {
work = new_state;
entries.push(json!({
"action_id": action_id,
"actor_id": actor_id,
"degree": degree,
"mutations_applied": serialize_mutations(&applied),
}));
resolved += 1;
}
Err(_) => {
prng.restore(snap); entries.push(json!({
"action_id": action_id,
"actor_id": actor_id,
"reason": REASON_EVAL_ERROR,
}));
rejected += 1;
}
}
}
let out_state = with_epoch(&work, input.epoch_number);
let event = json!({
"event_type": "EpochResolved",
"epoch_number": input.epoch_number,
"actions_processed": Value::Array(entries),
"pcg_steps_consumed": prng.get_draws(),
});
Ok(TickEpochResult {
state: out_state,
event,
resolved,
rejected,
})
}
pub struct CatchUpInput<'a> {
pub world_id: &'a str,
pub state: &'a Value,
pub current_epoch: i64,
pub max_catchup: i64,
pub ruleset: &'a Value,
pub proposals_by_epoch: &'a Value,
pub actor_tags: Vec<String>,
pub max_actions: Option<u64>,
}
pub struct CatchUpResult {
pub state: Value,
pub events: Vec<Value>,
pub epochs_resolved: i64,
pub epochs_voided: i64,
}
pub fn catch_up_epochs(input: CatchUpInput) -> Result<CatchUpResult, String> {
let client_epoch = input.state.get("epoch").and_then(|e| e.as_i64()).unwrap_or(0);
let target = input.current_epoch.checked_sub(client_epoch).unwrap_or(0);
if target <= 0 {
derive_epoch_prng(input.world_id, 0)?;
return Ok(CatchUpResult {
state: input.state.clone(),
events: Vec::new(),
epochs_resolved: 0,
epochs_voided: 0,
});
}
let capped = if target > input.max_catchup { input.max_catchup.max(0) } else { target };
let mut work = input.state.clone();
let mut events: Vec<Value> = Vec::new();
let empty = json!({});
let mut i = 1;
while i <= capped {
let epoch_n = match client_epoch.checked_add(i) {
Some(e) => e,
None => break, };
let proposals = input
.proposals_by_epoch
.get(epoch_n.to_string())
.filter(|p| p.is_object())
.unwrap_or(&empty);
let r = tick_epoch(TickEpochInput {
world_id: input.world_id,
state: &work,
epoch_number: epoch_n,
proposals,
ruleset: input.ruleset,
actor_tags: input.actor_tags.clone(),
max_actions: input.max_actions,
})?;
work = r.state;
events.push(r.event);
i += 1;
}
Ok(CatchUpResult {
state: work,
events,
epochs_resolved: capped,
epochs_voided: target - capped,
})
}
fn parse_actor_tags(v: &Value) -> Vec<String> {
v.get("actorTags")
.and_then(|t| t.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect())
.unwrap_or_default()
}
fn parse_max_actions(v: &Value) -> Result<Option<u64>, String> {
match v.get("maxActions") {
None | Some(Value::Null) => Ok(None),
Some(m) => {
let n = m
.as_u64()
.filter(|n| *n <= MAX_SAFE_INT as u64)
.ok_or("world-epoch: maxActions must be a non-negative JS-safe integer")?;
Ok(Some(n))
}
}
}
pub fn tick_epoch_from_json(input_json: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(input_json).map_err(|e| format!("world-epoch: bad tick input json: {}", e))?;
let world_id = v.get("worldId").and_then(|x| x.as_str()).ok_or("world-epoch: worldId must be a string")?;
let epoch_number = v.get("epochNumber").and_then(|x| x.as_i64()).ok_or("world-epoch: epochNumber must be an integer")?;
if !is_safe_epoch(epoch_number) {
return Err("world-epoch: epoch_number must be a JS-safe integer".to_string());
}
let max_actions = parse_max_actions(&v)?;
let r = tick_epoch(TickEpochInput {
world_id,
state: &v["state"],
epoch_number,
proposals: &v["proposals"],
ruleset: &v["ruleset"],
actor_tags: parse_actor_tags(&v),
max_actions,
})?;
let out = json!({ "state": r.state, "event": r.event, "resolved": r.resolved, "rejected": r.rejected });
serde_json::to_string(&out).map_err(|e| format!("world-epoch: serialize: {}", e))
}
pub fn catch_up_epochs_from_json(input_json: &str) -> Result<String, String> {
let v: Value = serde_json::from_str(input_json).map_err(|e| format!("world-epoch: bad catchup input json: {}", e))?;
let world_id = v.get("worldId").and_then(|x| x.as_str()).ok_or("world-epoch: worldId must be a string")?;
let current_epoch = v.get("currentEpoch").and_then(|x| x.as_i64()).ok_or("world-epoch: currentEpoch must be an integer")?;
if !is_safe_epoch(current_epoch) {
return Err("world-epoch: currentEpoch must be a JS-safe integer".to_string());
}
let max_catchup = v.get("maxCatchup").and_then(|x| x.as_i64()).ok_or("world-epoch: maxCatchup must be an integer")?;
if max_catchup < 0 || !is_safe_epoch(max_catchup) {
return Err("world-epoch: maxCatchup must be a non-negative JS-safe integer".to_string());
}
let client_epoch = v.get("state").and_then(|s| s.get("epoch")).and_then(|e| e.as_i64()).unwrap_or(0);
if !is_safe_epoch(client_epoch) {
return Err("world-epoch: state.epoch must be a JS-safe integer".to_string());
}
let max_actions = parse_max_actions(&v)?;
let r = catch_up_epochs(CatchUpInput {
world_id,
state: &v["state"],
current_epoch,
max_catchup,
ruleset: &v["ruleset"],
proposals_by_epoch: &v["proposalsByEpoch"],
actor_tags: parse_actor_tags(&v),
max_actions,
})?;
let out = json!({ "state": r.state, "events": r.events, "epochsResolved": r.epochs_resolved, "epochsVoided": r.epochs_voided });
serde_json::to_string(&out).map_err(|e| format!("world-epoch: serialize: {}", e))
}
pub const RESOURCE_WORLD_EPOCH: &str = "world_epoch";