use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FieldOp {
pub entity: Option<String>,
pub field: String,
pub value: Option<String>,
}
pub type FieldKey = (Option<String>, String);
impl FieldOp {
#[must_use]
pub fn key(&self) -> FieldKey {
(self.entity.clone(), self.field.clone())
}
}
const ENTITY: &str = "entity";
const ORDER: &str = "order";
const COMPONENTS: &str = "components/";
fn op(entity: Option<&str>, field: &str, value: Option<&Value>) -> FieldOp {
FieldOp {
entity: entity.map(str::to_owned),
field: field.to_owned(),
value: value.map(Value::to_string),
}
}
fn entities(scene: &Value) -> Vec<&Map<String, Value>> {
scene
.get("entities")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.collect()
}
fn id(entity: &Map<String, Value>) -> Option<&str> {
entity.get("id").and_then(Value::as_str)
}
fn ids(scene: &Value) -> Vec<String> {
entities(scene)
.into_iter()
.filter_map(|e| id(e).map(str::to_owned))
.collect()
}
fn diff_fields(
base: &Map<String, Value>,
ours: &Map<String, Value>,
skip: &[&str],
mut push: impl FnMut(&str, Option<&Value>),
) {
let keys: Vec<&String> = base.keys().chain(ours.keys()).collect();
let mut seen = HashSet::new();
for key in keys {
if skip.contains(&key.as_str()) || !seen.insert(key) {
continue;
}
let after = ours.get(key);
if base.get(key) != after {
push(key, after);
}
}
}
#[must_use]
pub fn diff_scene(before: &Value, after: &Value) -> Vec<FieldOp> {
let empty = Map::new();
let object = |v: &Value| v.as_object().cloned().unwrap_or_default();
let mut ops = Vec::new();
diff_fields(&object(before), &object(after), &["entities"], |k, v| {
ops.push(op(None, k, v));
});
let old = entities(before);
let find = |id_: &str| old.iter().copied().find(|e| id(e) == Some(id_));
let new = entities(after);
for entity in &new {
let Some(key) = id(entity) else { continue };
let Some(base) = find(key) else {
ops.push(op(
Some(key),
ENTITY,
Some(&Value::Object((*entity).clone())),
));
continue;
};
diff_fields(base, entity, &["id", "components"], |k, v| {
ops.push(op(Some(key), k, v));
});
let part = |e: &Map<String, Value>| {
e.get("components").and_then(Value::as_object).cloned()
};
let (b, o) = (part(base), part(entity));
diff_fields(
b.as_ref().unwrap_or(&empty),
o.as_ref().unwrap_or(&empty),
&[],
|k, v| ops.push(op(Some(key), &format!("{COMPONENTS}{k}"), v)),
);
}
for entity in &old {
if let Some(key) = id(entity) {
if !new.iter().any(|e| id(e) == Some(key)) {
ops.push(op(Some(key), ENTITY, None));
}
}
}
let mut applied = before.clone();
apply_ops(&mut applied, &ops, &HashSet::new());
let order = ids(after);
if ids(&applied) != order {
ops.push(op(None, ORDER, Some(&Value::from(order))));
}
ops
}
pub fn apply_ops(doc: &mut Value, ops: &[FieldOp], skip: &HashSet<FieldKey>) {
let Some(scene) = doc.as_object_mut() else {
return;
};
for op in ops.iter().filter(|op| !skip.contains(&op.key())) {
let value = match &op.value {
Some(text) => match serde_json::from_str::<Value>(text) {
Ok(value) => Some(value),
Err(_) => continue,
},
None => None,
};
let Some(entity) = &op.entity else {
if op.field == ORDER {
reorder(scene, value.as_ref());
} else if op.field != "entities" {
set(scene, &op.field, value);
}
continue;
};
let list = scene
.entry("entities")
.or_insert_with(|| Value::Array(Vec::new()));
let Some(list) = list.as_array_mut() else {
continue;
};
let index = list
.iter()
.position(|e| e.get("id").and_then(Value::as_str) == Some(entity));
if op.field == ENTITY {
match (index, value) {
(Some(i), Some(value)) => list[i] = value,
(None, Some(value)) => list.push(value),
(Some(i), None) => {
list.remove(i);
}
(None, None) => {}
}
continue;
}
let Some(target) = index.and_then(|i| list[i].as_object_mut()) else {
continue;
};
match op.field.strip_prefix(COMPONENTS) {
Some(name) => {
let components = target
.entry("components")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(components) = components.as_object_mut() {
set(components, name, value);
}
}
None if op.field != "id" => set(target, &op.field, value),
None => {}
}
}
}
fn set(object: &mut Map<String, Value>, key: &str, value: Option<Value>) {
match value {
Some(value) => {
object.insert(key.to_owned(), value);
}
None => {
object.remove(key);
}
}
}
fn reorder(scene: &mut Map<String, Value>, order: Option<&Value>) {
let Some(order) = order.and_then(Value::as_array) else {
return;
};
let Some(Value::Array(list)) = scene.get_mut("entities") else {
return;
};
let rank = |e: &Value| {
let id = e.get("id");
order
.iter()
.position(|o| Some(o) == id)
.unwrap_or(order.len())
};
list.sort_by_key(rank);
}
#[must_use]
pub fn pending_keys(synced: &Value, live: &Value) -> HashSet<FieldKey> {
diff_scene(synced, live).iter().map(FieldOp::key).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn entity(id: &str, x: f64) -> Value {
json!({"id": id, "name": id, "transform": {"position": [x, 0.0, 0.0]}, "components": {}})
}
fn scene(entities: Vec<Value>) -> Value {
json!({"format_version": 9, "name": "S", "entities": entities})
}
fn round_trip(before: &Value, after: &Value) -> Vec<FieldOp> {
let ops = diff_scene(before, after);
let mut applied = before.clone();
apply_ops(&mut applied, &ops, &HashSet::new());
assert_eq!(&applied, after, "ops: {ops:?}");
ops
}
#[test]
fn identical_scenes_have_no_ops() {
let s = scene(vec![entity("a", 0.0)]);
assert!(diff_scene(&s, &s).is_empty());
}
#[test]
fn field_change_is_one_op_on_that_field() {
let before = scene(vec![entity("a", 0.0), entity("b", 0.0)]);
let after = scene(vec![entity("a", 3.0), entity("b", 0.0)]);
let ops = round_trip(&before, &after);
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].key(), (Some("a".into()), "transform".into()));
}
#[test]
fn components_are_separate_fields() {
let before = scene(vec![entity("a", 0.0)]);
let mut tagged = entity("a", 0.0);
tagged["components"] = json!({"game.hp": 3, "game.tag": "x"});
let after = scene(vec![tagged.clone()]);
let ops = round_trip(&before, &after);
assert_eq!(ops.len(), 2);
assert!(ops.iter().all(|op| op.field.starts_with("components/")));
tagged["components"] = json!({"game.tag": "x"});
let removed = round_trip(&after, &scene(vec![tagged]));
assert_eq!(removed[0].field, "components/game.hp");
assert_eq!(removed[0].value, None);
}
#[test]
fn create_delete_and_reorder_round_trip() {
let before = scene(vec![entity("a", 0.0), entity("b", 0.0)]);
round_trip(
&before,
&scene(vec![entity("a", 0.0), entity("b", 0.0), entity("c", 1.0)]),
);
round_trip(&before, &scene(vec![entity("b", 0.0)]));
let ops = round_trip(
&before,
&scene(vec![entity("b", 0.0), entity("a", 0.0)]),
);
assert_eq!(ops.len(), 1);
assert_eq!(ops[0].field, "order");
round_trip(
&before,
&scene(vec![entity("a", 0.0), entity("c", 1.0), entity("b", 0.0)]),
);
}
#[test]
fn scene_level_fields_round_trip() {
let before = scene(vec![entity("a", 0.0)]);
let mut after = before.clone();
after["name"] = json!("Renamed");
after["render"] = json!({"fog": true});
let ops = round_trip(&before, &after);
assert!(ops.iter().all(|op| op.entity.is_none()));
}
#[test]
fn skip_keeps_pending_local_fields() {
let mut doc = scene(vec![entity("a", 0.0)]);
let ops = diff_scene(&doc, &scene(vec![entity("a", 5.0)]));
let skip =
HashSet::from([(Some("a".to_owned()), "transform".to_owned())]);
apply_ops(&mut doc, &ops, &skip);
assert_eq!(doc, scene(vec![entity("a", 0.0)]));
}
#[test]
fn ops_on_missing_entities_are_ignored() {
let mut doc = scene(vec![entity("a", 0.0)]);
let ops = diff_scene(
&scene(vec![entity("z", 0.0)]),
&scene(vec![entity("z", 4.0)]),
);
apply_ops(&mut doc, &ops, &HashSet::new());
assert_eq!(doc, scene(vec![entity("a", 0.0)]));
}
#[test]
fn concurrent_edits_to_different_entities_both_survive() {
let base = scene(vec![entity("a", 0.0), entity("b", 0.0)]);
let from_a =
diff_scene(&base, &scene(vec![entity("a", 1.0), entity("b", 0.0)]));
let from_b =
diff_scene(&base, &scene(vec![entity("a", 0.0), entity("b", 2.0)]));
let mut host = base.clone();
apply_ops(&mut host, &from_a, &HashSet::new());
apply_ops(&mut host, &from_b, &HashSet::new());
assert_eq!(host, scene(vec![entity("a", 1.0), entity("b", 2.0)]));
}
#[test]
fn pending_keys_name_unsent_fields() {
let synced = scene(vec![entity("a", 0.0)]);
let live = scene(vec![entity("a", 1.0)]);
assert_eq!(
pending_keys(&synced, &live),
HashSet::from([(Some("a".to_owned()), "transform".to_owned())])
);
}
}