use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action")]
pub enum Action {
#[serde(rename = "codex.open")]
CodexOpen { slug: String },
#[serde(rename = "commerce.buy")]
CommerceBuy {
item: String,
#[serde(default)]
price_ref: serde_json::Value,
},
#[serde(rename = "navigate")]
Navigate { to: String },
#[serde(rename = "notify")]
Notify {
text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
level: Option<String>,
},
#[serde(rename = "presence.emit")]
PresenceEmit {
event: String,
#[serde(default)]
data: serde_json::Value,
},
#[serde(rename = "set_state")]
SetState {
#[serde(default, skip_serializing_if = "Option::is_none")]
placement: Option<String>,
patch: serde_json::Value,
},
#[serde(rename = "give_item")]
GiveItem { item: u16, count: u32 },
#[serde(rename = "despawn")]
Despawn { position: [f32; 3] },
#[serde(rename = "spawn")]
Spawn {
builtin: String,
position: [f32; 3],
#[serde(default = "one_f32")]
scale: f32,
#[serde(default = "white_rgb")]
color: [f32; 3],
#[serde(default)]
name: String,
#[serde(default)]
animate: String,
#[serde(default = "one_f32")]
anim_speed: f32,
#[serde(default = "quarter_f32")]
anim_amp: f32,
},
}
fn quarter_f32() -> f32 {
0.25
}
fn one_f32() -> f32 {
1.0
}
fn white_rgb() -> [f32; 3] {
[1.0, 1.0, 1.0]
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ActionList {
#[serde(default)]
pub actions: Vec<Action>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Actor {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub passport_sub: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InteractEvent {
pub placement: String,
#[serde(default)]
pub actor: Actor,
pub world: String,
#[serde(default)]
pub data: serde_json::Value,
}
pub trait Behavior {
fn on_load(&mut self) {}
fn on_interact(&mut self, event: &InteractEvent) -> Vec<Action>;
fn on_tick(&mut self, _dt_ms: i32) {}
fn on_event(&mut self, _event: &serde_json::Value) -> Vec<Action> {
Vec::new()
}
}
pub fn parse_actions(json: &str) -> Vec<Action> {
serde_json::from_str::<ActionList>(json)
.map(|a| a.actions)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn actions_use_the_dotted_wire_names() {
let a = Action::CodexOpen {
slug: "the-thread".into(),
};
assert_eq!(
serde_json::to_value(&a).unwrap(),
json!({"action": "codex.open", "slug": "the-thread"})
);
let n = Action::Navigate {
to: "thread://pixygon.io".into(),
};
assert_eq!(serde_json::to_value(&n).unwrap()["action"], "navigate");
}
#[test]
fn action_list_round_trips() {
let list = ActionList {
actions: vec![
Action::Notify {
text: "Sold!".into(),
level: Some("info".into()),
},
Action::SetState {
placement: Some("shelf".into()),
patch: json!({"sold": true}),
},
],
};
let json = serde_json::to_string(&list).unwrap();
assert_eq!(parse_actions(&json), list.actions);
}
#[test]
fn malformed_returns_no_actions_not_an_error() {
assert!(parse_actions("not json").is_empty());
assert!(parse_actions("{}").is_empty());
}
struct CodexViewer;
impl Behavior for CodexViewer {
fn on_interact(&mut self, e: &InteractEvent) -> Vec<Action> {
match e.data.get("slug").and_then(|s| s.as_str()) {
Some(slug) => vec![Action::CodexOpen {
slug: slug.to_string(),
}],
None => vec![],
}
}
}
#[test]
fn codex_viewer_reference_behavior() {
let mut b = CodexViewer;
let ev = InteractEvent {
placement: "pedestal".into(),
actor: Actor::default(),
world: "codex-archive".into(),
data: json!({ "slug": "the-thread" }),
};
assert_eq!(
b.on_interact(&ev),
vec![Action::CodexOpen {
slug: "the-thread".into()
}]
);
}
}