use serde_json::{Value, json};
use vta_sdk::client::VtaClient;
use vta_sdk::error::VtaError;
use vta_sdk::trust_tasks;
use crate::errors::OpenVTCError;
const WORLD_TIMEOUT: u64 = 30;
const PAGE: u64 = 100;
const MAX_PAGES: usize = 32;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Colour {
#[default]
Slate,
Indigo,
Teal,
Moss,
Sand,
Clay,
Rose,
Plum,
}
impl Colour {
#[must_use]
pub fn all() -> [Colour; 8] {
[
Colour::Slate,
Colour::Indigo,
Colour::Teal,
Colour::Moss,
Colour::Sand,
Colour::Clay,
Colour::Rose,
Colour::Plum,
]
}
#[must_use]
pub fn as_wire(self) -> &'static str {
match self {
Colour::Slate => "slate",
Colour::Indigo => "indigo",
Colour::Teal => "teal",
Colour::Moss => "moss",
Colour::Sand => "sand",
Colour::Clay => "clay",
Colour::Rose => "rose",
Colour::Plum => "plum",
}
}
#[must_use]
pub fn from_wire(token: &str) -> Self {
Colour::all()
.into_iter()
.find(|c| c.as_wire() == token)
.unwrap_or(Colour::Slate)
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct World {
pub world_id: String,
pub name: String,
pub colour: Colour,
pub icon: Option<String>,
pub face_ids: Vec<String>,
pub attribute_ids: Vec<String>,
pub version: u64,
pub updated_at: String,
}
impl World {
fn from_wire(value: &Value) -> Self {
let ids = |member: &str| {
value
.get(member)
.and_then(Value::as_array)
.map(|xs| {
xs.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
};
Self {
world_id: value
.get("worldId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
name: value
.get("name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
colour: value
.get("colour")
.and_then(Value::as_str)
.map_or(Colour::Slate, Colour::from_wire),
icon: value
.get("icon")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string),
face_ids: ids("faceIds"),
attribute_ids: ids("attributeIds"),
version: value.get("version").and_then(Value::as_u64).unwrap_or(0),
updated_at: value
.get("updatedAt")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
}
}
#[must_use]
pub fn display_name(&self) -> &str {
match self.name.trim().is_empty() {
false => &self.name,
true => "(unnamed world)",
}
}
#[must_use]
pub fn holds_face(&self, profile_id: &str) -> bool {
self.face_ids.iter().any(|id| id == profile_id)
}
#[must_use]
pub fn to_draft(&self) -> WorldDraft {
WorldDraft {
world_id: Some(self.world_id.clone()),
name: self.name.clone(),
colour: self.colour,
icon: self.icon.clone(),
face_ids: self.face_ids.clone(),
attribute_ids: self.attribute_ids.clone(),
expected_version: Some(self.version),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorldDraft {
pub world_id: Option<String>,
pub name: String,
pub colour: Colour,
pub icon: Option<String>,
pub face_ids: Vec<String>,
pub attribute_ids: Vec<String>,
pub expected_version: Option<u64>,
}
impl WorldDraft {
#[must_use]
pub fn new(name: String, colour: Colour) -> Self {
Self {
name,
colour,
expected_version: Some(0),
..Self::default()
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct WorldWrite {
pub world_id: String,
pub version: u64,
pub created: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Deletion {
pub existed: bool,
pub released_faces: u64,
}
pub async fn list(client: &VtaClient) -> Result<Vec<World>, OpenVTCError> {
let mut worlds: Vec<World> = Vec::new();
let mut cursor: Option<String> = None;
for _ in 0..MAX_PAGES {
let mut payload = json!({ "limit": PAGE });
if let Some(c) = &cursor {
payload["cursor"] = json!(c);
}
let value = match client
.dispatch_trust_task(
trust_tasks::TASK_PERSONA_WORLD_LIST_1_0,
payload,
WORLD_TIMEOUT,
)
.await
{
Ok(value) => value,
Err(VtaError::UnsupportedTaskType { .. }) => return Ok(Vec::new()),
Err(e) => return Err(OpenVTCError::Vta(format!("persona world list failed: {e}"))),
};
worlds.extend(
value
.get("worlds")
.and_then(Value::as_array)
.map(|rows| rows.iter().map(World::from_wire))
.into_iter()
.flatten(),
);
cursor = value
.get("nextCursor")
.and_then(Value::as_str)
.filter(|c| !c.is_empty())
.map(str::to_string);
if cursor.is_none() {
break;
}
}
Ok(worlds)
}
pub async fn put(client: &VtaClient, draft: WorldDraft) -> Result<WorldWrite, OpenVTCError> {
let mut payload = json!({
"name": draft.name,
"colour": draft.colour.as_wire(),
"faceIds": draft.face_ids,
"attributeIds": draft.attribute_ids,
});
if let Some(id) = &draft.world_id {
payload["worldId"] = json!(id);
}
if let Some(icon) = draft.icon.as_deref().filter(|s| !s.is_empty()) {
payload["icon"] = json!(icon);
}
if let Some(version) = draft.expected_version {
payload["expectedVersion"] = json!(version);
}
let value = client
.dispatch_trust_task(
trust_tasks::TASK_PERSONA_WORLD_PUT_1_0,
payload,
WORLD_TIMEOUT,
)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona world write failed: {e}")))?;
Ok(WorldWrite {
world_id: value
.get("worldId")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
version: value.get("version").and_then(Value::as_u64).unwrap_or(0),
created: value
.get("created")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
pub async fn delete(
client: &VtaClient,
world_id: &str,
expected_version: Option<u64>,
) -> Result<Deletion, OpenVTCError> {
let mut payload = json!({ "worldId": world_id });
if let Some(version) = expected_version {
payload["expectedVersion"] = json!(version);
}
let value = client
.dispatch_trust_task(
trust_tasks::TASK_PERSONA_WORLD_DELETE_1_0,
payload,
WORLD_TIMEOUT,
)
.await
.map_err(|e| OpenVTCError::Vta(format!("persona world delete failed: {e}")))?;
Ok(Deletion {
existed: value
.get("existed")
.and_then(Value::as_bool)
.unwrap_or(false),
released_faces: value
.get("releasedFaces")
.and_then(Value::as_u64)
.unwrap_or(0),
})
}
pub async fn place_face(
client: &VtaClient,
worlds: &[World],
profile_id: &str,
into: Option<&str>,
) -> Result<(), OpenVTCError> {
for world in worlds.iter().filter(|f| f.holds_face(profile_id)) {
if Some(world.world_id.as_str()) == into {
return Ok(());
}
let mut draft = world.to_draft();
draft.face_ids.retain(|id| id != profile_id);
put(client, draft).await?;
}
let Some(target) = into else {
return Ok(());
};
let Some(world) = worlds.iter().find(|f| f.world_id == target) else {
return Err(OpenVTCError::Vta(format!(
"no such world: {target}. It may have been deleted from another \
client since this list was read"
)));
};
let mut draft = world.to_draft();
draft.face_ids.push(profile_id.to_string());
put(client, draft).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn wire(name: &str, faces: &[&str]) -> Value {
json!({
"worldId": "01FACET",
"name": name,
"colour": "moss",
"icon": "🏠",
"faceIds": faces,
"attributeIds": ["01ATTR"],
"version": 3,
"updatedAt": "2026-09-09T10:00:00Z",
})
}
#[test]
fn a_world_is_read_whole() {
let world = World::from_wire(&wire("Home", &["01FACE"]));
assert_eq!(world.world_id, "01FACET");
assert_eq!(world.name, "Home");
assert_eq!(world.colour, Colour::Moss);
assert_eq!(world.icon.as_deref(), Some("🏠"));
assert_eq!(world.face_ids, vec!["01FACE".to_string()]);
assert_eq!(world.attribute_ids, vec!["01ATTR".to_string()]);
assert_eq!(world.version, 3);
}
#[test]
fn an_unknown_colour_does_not_lose_the_world() {
let mut value = wire("Home", &[]);
value["colour"] = json!("chartreuse");
let world = World::from_wire(&value);
assert_eq!(world.colour, Colour::Slate);
assert_eq!(world.name, "Home");
}
#[test]
fn every_colour_round_trips() {
for colour in Colour::all() {
assert_eq!(Colour::from_wire(colour.as_wire()), colour);
}
}
#[test]
fn an_edit_carries_the_whole_membership_forward() {
let world = World::from_wire(&wire("Home", &["01FACE", "02FACE"]));
let mut draft = world.to_draft();
draft.name = "Home life".to_string();
assert_eq!(
draft.face_ids,
vec!["01FACE".to_string(), "02FACE".to_string()]
);
assert_eq!(draft.attribute_ids, vec!["01ATTR".to_string()]);
assert_eq!(draft.expected_version, Some(3));
assert_eq!(draft.world_id.as_deref(), Some("01FACET"));
}
#[test]
fn a_new_world_is_create_only() {
let draft = WorldDraft::new("Play".to_string(), Colour::Rose);
assert_eq!(draft.expected_version, Some(0));
assert!(draft.world_id.is_none());
assert!(draft.face_ids.is_empty());
}
#[test]
fn an_unnamed_world_still_has_something_to_draw() {
let mut value = wire("", &[]);
value["name"] = json!(" ");
assert_eq!(World::from_wire(&value).display_name(), "(unnamed world)");
}
#[test]
fn a_world_knows_which_faces_it_holds() {
let world = World::from_wire(&wire("Home", &["01FACE"]));
assert!(world.holds_face("01FACE"));
assert!(!world.holds_face("02FACE"));
}
}