use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::doc::{ApplyError, WorldDoc};
use crate::history::EditOp;
use crate::oplog::OpLogEntry;
pub const SESSION_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum SessionOp {
Edit(Box<EditOp>),
Tool(ToolRecord),
Input(InputRecord),
State(StateRecord),
Clock(ClockRecord),
Merge(MergeRecord),
Extension(ExtensionRecord),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExtensionRecord {
pub name: String,
pub body: serde_json::Value,
}
impl Serialize for ExtensionRecord {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let mut map = serde_json::Map::new();
map.insert(self.name.clone(), self.body.clone());
map.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for ExtensionRecord {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let map = serde_json::Map::<String, serde_json::Value>::deserialize(deserializer)?;
let mut iter = map.into_iter();
let (name, body) = iter.next().ok_or_else(|| {
<D::Error as serde::de::Error>::custom("an extension op holds one key")
})?;
if iter.next().is_some() {
return Err(<D::Error as serde::de::Error>::custom(
"an extension op holds exactly one key",
));
}
if !name.starts_with("ext-") {
return Err(<D::Error as serde::de::Error>::custom(format!(
"'{name}' is not an extension namespace (ext-…)"
)));
}
Ok(ExtensionRecord { name, body })
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeRecord {
pub branch: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolRecord {
pub tool: String,
pub args: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputRecord {
pub input: InputSample,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputSample {
pub actor: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<[f32; 3]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub look: Option<[f32; 2]>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub click: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateRecord {
pub state: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClockRecord {
pub clock: ClockState,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClockState {
pub playing: bool,
pub position_s: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionMeta {
pub format_version: u32,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
pub base_revision: u64,
pub head_revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seed: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub world_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub log_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub forked_from: Option<String>,
#[serde(default)]
pub updated_ms: u64,
}
impl SessionMeta {
pub fn new(name: impl Into<String>) -> Self {
Self {
format_version: SESSION_FORMAT_VERSION,
name: name.into(),
app: None,
base_revision: 0,
head_revision: 0,
seed: None,
world_sha256: None,
log_sha256: None,
forked_from: None,
updated_ms: 0,
}
}
}
impl SessionOp {
pub fn as_edit(&self) -> Option<&EditOp> {
match self {
SessionOp::Edit(op) => Some(op),
_ => None,
}
}
}
impl OpLogEntry {
pub fn edit_ops(&self) -> Vec<EditOp> {
self.ops
.iter()
.filter_map(|op| match op {
SessionOp::Edit(edit) => Some((**edit).clone()),
_ => None,
})
.collect()
}
pub fn is_history_only(&self) -> bool {
self.ops.iter().all(|op| !matches!(op, SessionOp::Edit(_)))
}
}
struct Identified<'a> {
entry: &'a OpLogEntry,
id: String,
parent: Option<String>,
}
fn with_identity(entries: &[OpLogEntry]) -> Result<Vec<Identified<'_>>, ApplyError> {
let mut out: Vec<Identified<'_>> = Vec::with_capacity(entries.len());
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut previous: Option<String> = None;
for (n, entry) in entries.iter().enumerate() {
let id = entry.id.clone().unwrap_or_else(|| format!("line-{n}"));
if !seen.insert(id.clone()) {
return Err(ApplyError::Invalid(format!("duplicate entry id '{id}'")));
}
if let Some(parent) = entry.parent.clone().or_else(|| previous.clone())
&& !seen.contains(&parent)
{
return Err(ApplyError::Invalid(format!(
"entry '{id}' names parent '{parent}', which isn't in the log yet"
)));
}
let parent = entry.parent.clone().or_else(|| previous.clone());
out.push(Identified {
entry,
id: id.clone(),
parent,
});
previous = Some(id);
}
Ok(out)
}
pub fn fold_path(
base: &WorldDoc,
entries: &[OpLogEntry],
tip: Option<&str>,
) -> Result<(WorldDoc, Vec<String>), ApplyError> {
let identified = with_identity(entries)?;
let target = match tip {
Some(id) => identified
.iter()
.find(|e| e.id == id)
.map(|e| e.id.clone())
.ok_or_else(|| ApplyError::Invalid(format!("no entry '{id}' in this log")))?,
None => identified
.last()
.ok_or_else(|| ApplyError::Invalid("the log is empty".into()))?
.id
.clone(),
};
let by_id: std::collections::HashMap<&str, usize> = identified
.iter()
.enumerate()
.map(|(i, e)| (e.id.as_str(), i))
.collect();
let mut chain_idx = Vec::new();
let mut cursor = Some(target);
while let Some(id) = cursor {
let idx = *by_id
.get(id.as_str())
.ok_or_else(|| ApplyError::Invalid(format!("no entry '{}' in this log", id)))?;
let parent = identified[idx].parent.clone();
chain_idx.push(idx);
cursor = parent;
}
chain_idx.reverse();
let chain: Vec<OpLogEntry> = chain_idx
.iter()
.map(|&i| identified[i].entry.clone())
.collect();
let path: Vec<String> = chain_idx
.iter()
.map(|&i| identified[i].id.clone())
.collect();
let doc = fold_log(base, &chain)?;
Ok((doc, path))
}
pub fn fold_log(base: &WorldDoc, entries: &[OpLogEntry]) -> Result<WorldDoc, ApplyError> {
let mut doc = base.clone();
for entry in entries {
doc.apply_all(&entry.edit_ops())?;
}
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
use crate as wt;
use crate::author::Author;
fn entity(id: u64, name: &str) -> wt::WorldEntity {
let mut e = wt::WorldEntity::new(id, name);
e.transform.position = [id as f32, 0.0, 0.0];
e
}
#[test]
fn edit_serializes_as_the_old_format_did() {
let op = SessionOp::Edit(Box::new(EditOp::spawn(entity(1, "lighthouse"))));
let json = serde_json::to_string(&op).unwrap();
assert!(json.starts_with("{\"SpawnEntity\""));
let back: SessionOp = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&back).unwrap(), json);
}
#[test]
fn old_log_line_parses_as_edits() {
let line = r#"{"revision":7,"author":{"peer":3,"name":"maya"},
"ops":[{"SpawnEntity":{"entity":{"id":1,"name":"lighthouse"}}}],
"timestamp_ms":1700000000000}"#;
let entry: OpLogEntry = serde_json::from_str(line).unwrap();
assert_eq!(entry.revision, 7);
assert_eq!(entry.edit_ops().len(), 1);
assert!(!entry.is_history_only());
}
#[test]
fn tool_input_state_clock_roundtrip_and_skip() {
let entry = OpLogEntry {
revision: 42,
author: Author {
peer: None,
name: "llm".into(),
},
ops: vec![
SessionOp::Tool(ToolRecord {
tool: "gen_spawn_primitive".into(),
args: serde_json::json!({"name": "cube"}),
result_hash: Some("sha256:ab".into()),
phase: Some("blockout".into()),
timestamp_ms: Some(7),
}),
SessionOp::Input(InputRecord {
input: InputSample {
actor: "visitor-7".into(),
position: Some([3.0, 1.8, -2.0]),
look: None,
click: Some(17),
},
}),
SessionOp::State(StateRecord {
state: BTreeMap::from([("score.chest".into(), serde_json::json!(10))]),
}),
SessionOp::Clock(ClockRecord {
clock: ClockState {
playing: true,
position_s: 41.5,
},
}),
],
timestamp_ms: 9,
id: None,
parent: None,
};
let line = crate::encode_line(&entry).unwrap();
let back: OpLogEntry = crate::decode_line(&line).unwrap();
assert_eq!(back.revision, 42);
assert_eq!(back.ops.len(), 4);
assert!(back.is_history_only());
assert!(entry.is_history_only());
assert!(entry.edit_ops().is_empty());
}
fn branched_entries() -> Vec<OpLogEntry> {
let mk = |id: Option<&str>, parent: Option<&str>, rev: u64, name: &str| OpLogEntry {
revision: rev,
author: Author {
peer: None,
name: "maya".into(),
},
ops: vec![SessionOp::Edit(Box::new(EditOp::spawn(entity(
100 + rev,
name,
))))],
timestamp_ms: rev,
id: id.map(Into::into),
parent: parent.map(Into::into),
};
vec![
mk(Some("e1"), None, 1, "keep"),
mk(Some("e2"), Some("e1"), 2, "wall"),
mk(Some("e3"), Some("e2"), 3, "garden"),
mk(Some("e4"), Some("e2"), 3, "moat"),
OpLogEntry {
revision: 4,
author: Author {
peer: None,
name: "host".into(),
},
ops: vec![SessionOp::Merge(MergeRecord {
branch: "moat-variant".into(),
})],
timestamp_ms: 4,
id: Some("e5".into()),
parent: Some("e4".into()),
},
]
}
#[test]
fn fold_path_folds_each_tip_of_a_branch() {
let base = WorldDoc::new("base");
let entries = branched_entries();
let (trunk, path) = fold_path(&base, &entries, Some("e3")).unwrap();
assert_eq!(path, vec!["e1", "e2", "e3"]);
assert!(trunk.get_by_name("garden").is_some());
assert!(trunk.get_by_name("moat").is_none());
let (variant, path) = fold_path(&base, &entries, Some("e5")).unwrap();
assert_eq!(path, vec!["e1", "e2", "e4", "e5"]);
assert!(variant.get_by_name("moat").is_some());
assert!(variant.get_by_name("garden").is_none());
assert_eq!(
variant.len(),
fold_path(&base, &entries, Some("e4")).unwrap().0.len()
);
let (last, _) = fold_path(&base, &entries, None).unwrap();
assert!(last.get_by_name("moat").is_some());
assert!(fold_path(&base, &entries, Some("e99")).is_err());
}
#[test]
fn entries_without_ids_chain_in_file_order() {
let base = WorldDoc::new("base");
let entries = vec![
OpLogEntry {
revision: 1,
author: Author {
peer: None,
name: "t".into(),
},
ops: vec![SessionOp::Edit(Box::new(EditOp::spawn(entity(1, "a"))))],
timestamp_ms: 0,
id: None,
parent: None,
},
OpLogEntry {
revision: 2,
author: Author {
peer: None,
name: "t".into(),
},
ops: vec![SessionOp::Edit(Box::new(EditOp::spawn(entity(2, "b"))))],
timestamp_ms: 1,
id: None,
parent: None,
},
];
let (doc, path) = fold_path(&base, &entries, None).unwrap();
assert_eq!(doc.len(), 2);
assert_eq!(path, vec!["line-0", "line-1"]);
}
#[test]
fn fold_log_applies_edits_and_skips_history() {
let base = WorldDoc::new("base");
let entries = vec![
OpLogEntry {
revision: 1,
author: Author {
peer: None,
name: "llm".into(),
},
ops: vec![SessionOp::Edit(Box::new(EditOp::spawn(entity(1, "a"))))],
timestamp_ms: 0,
id: None,
parent: None,
},
OpLogEntry {
revision: 1,
author: Author {
peer: None,
name: "llm".into(),
},
ops: vec![SessionOp::Tool(ToolRecord {
tool: "gen_modify_entity".into(),
args: serde_json::json!({"entity": "a"}),
result_hash: None,
phase: None,
timestamp_ms: None,
})],
timestamp_ms: 1,
id: None,
parent: None,
},
OpLogEntry {
revision: 2,
author: Author {
peer: None,
name: "maya".into(),
},
ops: vec![SessionOp::Edit(Box::new(EditOp::spawn(entity(2, "b"))))],
timestamp_ms: 2,
id: None,
parent: None,
},
];
let doc = fold_log(&base, &entries).unwrap();
assert_eq!(doc.len(), 2);
assert!(doc.contains(2));
let broken = vec![OpLogEntry {
revision: 3,
author: Author {
peer: None,
name: "x".into(),
},
ops: vec![SessionOp::Edit(Box::new(EditOp::delete(wt::EntityId(99))))],
timestamp_ms: 3,
id: None,
parent: None,
}];
assert!(fold_log(&base, &broken).is_err());
}
}