vifu-runtime 0.1.0

Small, stateful, embeddable Agent Runtime primitives for Vifu.
Documentation
use std::collections::VecDeque;

use bevy_app::{App, Plugin};
use bevy_ecs::prelude::*;
use bevy_ecs::schedule::ScheduleLabel;
use serde::{Deserialize, Serialize};
use serde_json::Value;

#[derive(Clone, Debug, Hash, PartialEq, Eq, ScheduleLabel)]
pub struct RuntimeSchedule;

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeCommand {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub payload: Value,
}

impl RuntimeCommand {
    pub fn new(id: impl Into<String>, name: impl Into<String>, payload: Value) -> Self {
        Self {
            id: id.into(),
            name: name.into(),
            payload,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeEvent {
    pub sequence: u64,
    pub name: String,
    #[serde(default)]
    pub payload: Value,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EffectRequest {
    pub id: String,
    pub kind: String,
    #[serde(default)]
    pub payload: Value,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EffectResult {
    pub effect_id: String,
    pub succeeded: bool,
    #[serde(default)]
    pub output: Value,
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeSnapshot {
    pub revision: u64,
    #[serde(default)]
    pub state: Value,
}

impl Default for RuntimeSnapshot {
    fn default() -> Self {
        Self {
            revision: 0,
            state: Value::Object(Default::default()),
        }
    }
}

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RuntimeAdvance {
    pub snapshot: RuntimeSnapshot,
    pub events: Vec<RuntimeEvent>,
    pub effects: Vec<EffectRequest>,
}

#[derive(Resource, Default)]
pub struct RuntimeCommandQueue {
    commands: VecDeque<RuntimeCommand>,
}

impl RuntimeCommandQueue {
    pub fn push(&mut self, command: RuntimeCommand) {
        self.commands.push_back(command);
    }

    pub fn pop_front(&mut self) -> Option<RuntimeCommand> {
        self.commands.pop_front()
    }

    pub fn drain(&mut self) -> impl Iterator<Item = RuntimeCommand> + '_ {
        self.commands.drain(..)
    }

    pub fn is_empty(&self) -> bool {
        self.commands.is_empty()
    }
}

#[derive(Resource, Default)]
pub struct RuntimeEventQueue {
    next_sequence: u64,
    events: Vec<RuntimeEvent>,
}

impl RuntimeEventQueue {
    pub fn emit(&mut self, name: impl Into<String>, payload: Value) -> u64 {
        let sequence = self.next_sequence;
        self.next_sequence = self.next_sequence.saturating_add(1);
        self.events.push(RuntimeEvent {
            sequence,
            name: name.into(),
            payload,
        });
        sequence
    }

    pub fn drain(&mut self) -> impl Iterator<Item = RuntimeEvent> + '_ {
        self.events.drain(..)
    }
}

#[derive(Resource, Default)]
pub struct EffectRequestQueue {
    next_id: u64,
    effects: Vec<EffectRequest>,
}

impl EffectRequestQueue {
    pub fn request(&mut self, kind: impl Into<String>, payload: Value) -> String {
        let id = format!("effect-{}", self.next_id);
        self.next_id = self.next_id.saturating_add(1);
        self.effects.push(EffectRequest {
            id: id.clone(),
            kind: kind.into(),
            payload,
        });
        id
    }

    pub fn drain(&mut self) -> impl Iterator<Item = EffectRequest> + '_ {
        self.effects.drain(..)
    }
}

#[derive(Resource, Default)]
pub struct EffectResultQueue {
    results: VecDeque<EffectResult>,
}

impl EffectResultQueue {
    pub fn push(&mut self, result: EffectResult) {
        self.results.push_back(result);
    }

    pub fn pop_front(&mut self) -> Option<EffectResult> {
        self.results.pop_front()
    }

    pub fn drain(&mut self) -> impl Iterator<Item = EffectResult> + '_ {
        self.results.drain(..)
    }
}

#[derive(Resource, Clone, Debug)]
pub struct RuntimeState {
    pub revision: u64,
    pub value: Value,
}

impl Default for RuntimeState {
    fn default() -> Self {
        Self {
            revision: 0,
            value: Value::Object(Default::default()),
        }
    }
}

impl RuntimeState {
    pub fn snapshot(&self) -> RuntimeSnapshot {
        RuntimeSnapshot {
            revision: self.revision,
            state: self.value.clone(),
        }
    }
}

#[derive(Clone, Copy, Debug, Default)]
pub struct VifuRuntimePlugin;

impl Plugin for VifuRuntimePlugin {
    fn build(&self, app: &mut App) {
        app.init_schedule(RuntimeSchedule)
            .init_resource::<RuntimeCommandQueue>()
            .init_resource::<RuntimeEventQueue>()
            .init_resource::<EffectRequestQueue>()
            .init_resource::<EffectResultQueue>()
            .init_resource::<RuntimeState>();
    }
}

pub struct HeadlessRuntime {
    app: App,
}

impl Default for HeadlessRuntime {
    fn default() -> Self {
        Self::new()
    }
}

impl HeadlessRuntime {
    pub fn new() -> Self {
        let mut app = App::empty();
        app.add_plugins(VifuRuntimePlugin);
        Self { app }
    }

    pub fn restore(snapshot: RuntimeSnapshot) -> Self {
        let mut runtime = Self::new();
        runtime.app.insert_resource(RuntimeState {
            revision: snapshot.revision,
            value: snapshot.state,
        });
        runtime
    }

    pub fn app(&self) -> &App {
        &self.app
    }

    pub fn app_mut(&mut self) -> &mut App {
        &mut self.app
    }

    pub fn snapshot(&self) -> RuntimeSnapshot {
        self.app.world().resource::<RuntimeState>().snapshot()
    }

    pub fn complete_effect(&mut self, result: EffectResult) {
        self.app
            .world_mut()
            .resource_mut::<EffectResultQueue>()
            .push(result);
    }

    pub fn enqueue_command(&mut self, command: RuntimeCommand) {
        self.app
            .world_mut()
            .resource_mut::<RuntimeCommandQueue>()
            .push(command);
    }

    pub fn run_schedule(&mut self, schedule: impl ScheduleLabel) -> RuntimeAdvance {
        self.app.world_mut().run_schedule(schedule);
        self.take_advance()
    }

    pub fn dispatch(&mut self, command: RuntimeCommand) -> RuntimeAdvance {
        self.enqueue_command(command);
        self.app.world_mut().run_schedule(RuntimeSchedule);

        {
            let mut state = self.app.world_mut().resource_mut::<RuntimeState>();
            state.revision = state.revision.saturating_add(1);
        }

        self.take_advance()
    }

    fn take_advance(&mut self) -> RuntimeAdvance {
        let snapshot = self.snapshot();
        let events = self
            .app
            .world_mut()
            .resource_mut::<RuntimeEventQueue>()
            .drain()
            .collect();
        let effects = self
            .app
            .world_mut()
            .resource_mut::<EffectRequestQueue>()
            .drain()
            .collect();

        RuntimeAdvance {
            snapshot,
            events,
            effects,
        }
    }
}

#[cfg(test)]
mod tests {
    use bevy_ecs::prelude::{ResMut, Resource};
    use serde_json::json;

    use super::*;

    #[derive(Resource, Default)]
    struct ProcessedCommands(u64);

    fn echo_commands(
        mut commands: ResMut<RuntimeCommandQueue>,
        mut events: ResMut<RuntimeEventQueue>,
        mut effects: ResMut<EffectRequestQueue>,
        mut state: ResMut<RuntimeState>,
        mut processed: ResMut<ProcessedCommands>,
    ) {
        for command in commands.drain() {
            processed.0 += 1;
            state.value["lastCommand"] = Value::String(command.name.clone());
            events.emit("command.processed", json!({ "commandId": command.id }));
            effects.request("agent.invoke", command.payload);
        }
    }

    #[test]
    fn plugins_define_runtime_behavior() {
        let mut runtime = HeadlessRuntime::new();
        runtime
            .app_mut()
            .init_resource::<ProcessedCommands>()
            .add_systems(RuntimeSchedule, echo_commands);

        let advance = runtime.dispatch(RuntimeCommand::new(
            "command-1",
            "player.message",
            json!({ "text": "Hello" }),
        ));

        assert_eq!(advance.snapshot.revision, 1);
        assert_eq!(advance.snapshot.state["lastCommand"], "player.message");
        assert_eq!(advance.events[0].name, "command.processed");
        assert_eq!(advance.effects[0].kind, "agent.invoke");
    }

    #[test]
    fn snapshots_can_be_restored() {
        let runtime = HeadlessRuntime::restore(RuntimeSnapshot {
            revision: 7,
            state: json!({ "scene": "platform" }),
        });

        assert_eq!(runtime.snapshot().revision, 7);
        assert_eq!(runtime.snapshot().state["scene"], "platform");
    }

    #[derive(Clone, Debug, Hash, PartialEq, Eq, ScheduleLabel)]
    struct ExtensionSchedule;

    fn run_extension(
        mut commands: ResMut<RuntimeCommandQueue>,
        mut events: ResMut<RuntimeEventQueue>,
        mut state: ResMut<RuntimeState>,
    ) {
        let command = commands.pop_front().expect("extension command");
        state.revision = 9;
        state.value = command.payload;
        events.emit("extension.completed", json!({ "commandId": command.id }));
    }

    #[test]
    fn extensions_run_on_the_shared_runtime_host() {
        let mut runtime = HeadlessRuntime::new();
        runtime
            .app_mut()
            .init_schedule(ExtensionSchedule)
            .add_systems(ExtensionSchedule, run_extension);
        runtime.enqueue_command(RuntimeCommand::new(
            "command-9",
            "extension.run",
            json!({ "result": "ok" }),
        ));

        let advance = runtime.run_schedule(ExtensionSchedule);

        assert_eq!(advance.snapshot.revision, 9);
        assert_eq!(advance.snapshot.state["result"], "ok");
        assert_eq!(advance.events[0].name, "extension.completed");
    }
}