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
//! The replay archive: one session's frames, for the player.
//!
//! Gameplay only. Telemetry, series, events and stats live in the metrics
//! file beside it ([`crate::metrics`]), so the player never sees telemetry and
//! the site never decodes a frame. The signed archive hash covers both files.
//!
//! Encoded with postcard behind a magic and a format version, so a reader
//! refuses a file it can't read rather than mis-reading it.

use serde::{Deserialize, Serialize};

const MAGIC: &[u8; 4] = b"BSRA";
/// Bumped on any change to these types.
pub const ARCHIVE_FORMAT: u32 = 1;

/// Everything needed to read the frames without asking the platform.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ArchiveHeader {
    pub arena_id: String,
    pub arena_version: String,
    /// `sha256-<hex>` of the engine that ran, or empty for a local build.
    pub arena_digest: String,
    pub payload_schema_version: u32,
    /// Seat names, in seat order. Seats were drawn by the enclave.
    pub seats: Vec<String>,
    pub control_rate: u32,
    /// Frames recorded, including turn 0's.
    pub turns: u32,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Archive {
    pub header: ArchiveHeader,
    /// One complete-state frame per turn, turn 0 first. Opaque to everything
    /// but the arena's own player.
    pub frames: Vec<Vec<u8>>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ArchiveError {
    BadMagic,
    UnsupportedFormat(u32),
    Corrupt(String),
}

impl std::fmt::Display for ArchiveError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::BadMagic => write!(f, "not a BotStadia replay archive"),
            Self::UnsupportedFormat(format) => {
                write!(f, "archive format {format} isn't one this reader knows")
            }
            Self::Corrupt(text) => write!(f, "archive is corrupt: {text}"),
        }
    }
}

impl std::error::Error for ArchiveError {}

impl Archive {
    pub fn encode(&self) -> Vec<u8> {
        let mut bytes = Vec::new();
        bytes.extend_from_slice(MAGIC);
        bytes.extend_from_slice(&ARCHIVE_FORMAT.to_le_bytes());
        bytes
            .extend_from_slice(&postcard::to_allocvec(self).expect("an archive always serialises"));
        bytes
    }

    pub fn decode(bytes: &[u8]) -> Result<Self, ArchiveError> {
        if bytes.len() < 8 || &bytes[..4] != MAGIC {
            return Err(ArchiveError::BadMagic);
        }
        let format = u32::from_le_bytes(bytes[4..8].try_into().expect("four bytes"));
        if format != ARCHIVE_FORMAT {
            return Err(ArchiveError::UnsupportedFormat(format));
        }
        postcard::from_bytes(&bytes[8..]).map_err(|error| ArchiveError::Corrupt(error.to_string()))
    }
}

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

    #[test]
    fn round_trips_and_refuses_strangers() {
        let archive = Archive {
            header: ArchiveHeader {
                arena_id: "sample".into(),
                arena_version: "0.1.0".into(),
                arena_digest: String::new(),
                payload_schema_version: 1,
                seats: vec!["a".into(), "b".into()],
                control_rate: 50,
                turns: 2,
            },
            frames: vec![vec![1, 2], vec![3]],
        };
        let bytes = archive.encode();
        assert_eq!(Archive::decode(&bytes).unwrap(), archive);
        assert_eq!(Archive::decode(b"nope"), Err(ArchiveError::BadMagic));
        let mut future = bytes.clone();
        future[4] = 9;
        assert_eq!(
            Archive::decode(&future),
            Err(ArchiveError::UnsupportedFormat(9))
        );
    }
}