botstadia-interface 0.1.0

BotStadia's shared contracts: arena and agent WIT worlds, arena declarations, payloads, replay archives, metrics files and player messages.
Documentation
//! Messages between the site and an arena's player.
//!
//! The player runs in a frame on the CDN's domain. The site loads its
//! `index.html`, waits for [`PlayerMessage::Ready`], sends [`SiteMessage::Init`],
//! and from then on drives playback while the player reports the turn it's
//! showing. Messages are JSON strings in `postMessage`, tagged by `type`.
//! Unknown messages are ignored, so either side can be newer.
//!
//! The site draws charts, highlight marks, stats and the result from the
//! metrics file. The player draws only the stage.

use serde::{Deserialize, Serialize};

/// Bumped when a message changes shape.
pub const PROTOCOL: u32 = 1;

/// Site → player.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SiteMessage {
    Init {
        protocol: u32,
        /// Where to fetch the replay archive. `None` opens the stage alone,
        /// for authoring: authored cameras, no playback.
        replay_url: Option<String>,
        /// Seat names, in seat order.
        seats: Vec<String>,
        control_rate: u32,
        /// The last turn, so the transport knows the length before loading.
        turns: u32,
    },
    Play,
    Pause,
    /// Show this turn. `seq` is echoed back so a stale report can't move a
    /// scrubber that's being dragged.
    Seek {
        turn: u32,
        seq: u32,
    },
    Speed {
        multiplier: f64,
    },
    View {
        id: String,
    },
}

/// Player → site.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PlayerMessage {
    Ready {
        protocol: u32,
    },
    Progress {
        fraction: f32,
        label: String,
    },
    /// Everything is loaded and turn 0 is showing.
    Loaded {
        turns: u32,
        views: Vec<View>,
        view: String,
    },
    /// The turn on screen, for the site's playhead.
    Turn {
        turn: u32,
        seq: Option<u32>,
    },
    Playing {
        playing: bool,
        speed: f64,
    },
    Finished,
    /// A failure, worded to show as is.
    Error {
        message: String,
    },
}

/// A camera viewers can pick.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct View {
    pub id: String,
    pub label: String,
}

macro_rules! json {
    ($type:ty) => {
        impl $type {
            pub fn to_json(&self) -> String {
                serde_json::to_string(self).expect("messages always serialise")
            }

            /// `None` for anything that isn't a message of this kind, which is
            /// ignored.
            pub fn from_json(text: &str) -> Option<Self> {
                serde_json::from_str(text).ok()
            }
        }
    };
}

json!(SiteMessage);
json!(PlayerMessage);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn tagged_json_round_trips_and_strangers_are_ignored() {
        let init = SiteMessage::Init {
            protocol: PROTOCOL,
            replay_url: None,
            seats: vec!["a".into()],
            control_rate: 50,
            turns: 10,
        };
        assert!(init.to_json().contains(r#""type":"init""#));
        assert_eq!(SiteMessage::from_json(&init.to_json()), Some(init));
        let turn = PlayerMessage::Turn {
            turn: 3,
            seq: Some(1),
        };
        assert_eq!(PlayerMessage::from_json(&turn.to_json()), Some(turn));
        assert_eq!(PlayerMessage::from_json(r#"{"type":"future_thing"}"#), None);
    }
}