cranium-api 0.2.0

A crate extending the Cranium AI library with utilities for using Cranium outside of Bevy as an AI Server.
Documentation
/* 
This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 
If a copy of the MPL was not distributed with this file, 
You can obtain one at https://mozilla.org/MPL/2.0/. 
*/
use core::{num::NonZero, time::Duration};

use bevy::{prelude::*};
use cranium_bevy_plugin::CraniumPlugin;

#[derive(Resource)]
struct AutoRunHeartbeatTimeout(core::time::Duration);

impl Default for AutoRunHeartbeatTimeout {
    fn default() -> Self {
        Self(core::time::Duration::from_mins(5))
    }
}

#[derive(Resource)]
struct AutoRunHeartbeatWrapPeriod(core::time::Duration);

impl Default for AutoRunHeartbeatWrapPeriod {
    fn default() -> Self {
        Self(core::time::Duration::from_hours(6))
    }
}


#[derive(Default, Resource)]
struct AutoRunHeartbeatTracker {
    last_tick: core::time::Duration
}


/// 
#[derive(Event)]
struct AutoRunHeartbeat;

/// Triggers AutoRunHeartbeat events, keeping the AutoRun-ing Cranium instance alive.
/// This function is expected to be called periodically by the user from downstream code 
/// as an alternative to driving the whole App themselves.
pub fn _heartbeat(
    mut commands: Commands
) {
    commands.trigger(AutoRunHeartbeat);
}

fn update_heartbeat(
    _trigger: On<AutoRunHeartbeat>,
    timer: Res<Time<Real>>,
    mut heartbeat_tracker: ResMut<AutoRunHeartbeatTracker>
) {
    let now = timer.elapsed_wrapped();
    heartbeat_tracker.last_tick = now;
}

fn setup_wrap_period(
    wrap_period: Res<AutoRunHeartbeatWrapPeriod>,
    mut timer: ResMut<Time<Real>>,
) {
    let period = wrap_period.0;
    timer.set_wrap_period(period);
}

/// A System that checks 
fn check_heartbeat_system(
    heartbeat_tracker: Res<AutoRunHeartbeatTracker>,
    heartbeat_timeout: Res<AutoRunHeartbeatTimeout>,
    timer: Res<Time<Real>>,
    mut app_exit: MessageWriter<AppExit>,
) {
    let timeout = heartbeat_timeout.0;
    let last_tick = heartbeat_tracker.last_tick;

    let now = timer.elapsed_wrapped();
    let now = match now < last_tick {
        // We need to ensure that wrapping doesn't cause issues.
        //
        // If the delta would be negative, that means the last heartbeat 
        // happened in the previous wrap period (or more, we can't tell).
        // We'll readd it to get a more realistic, positive delta value.
        //
        // Note that if someone set timeout >> wrap, the timeout will NEVER happen!
        false => now,
        true => {
            now + timer.wrap_period()
        },
    };

    let delta = now - last_tick;

    if delta > timeout {
        bevy::log::error!(
            "Cranium received no heartbeat in more than {:?}s (delta:{:?}s, last update time: {:?}s), quitting!", 
            timeout.as_secs(), delta.as_secs(), last_tick.as_secs()
        );
        app_exit.write(AppExit::Error(NonZero::new(1u8).unwrap()));
    };
}

pub fn create_app() -> App {
    let mut app = App::new();
    app.add_plugins(CraniumPlugin);

    #[cfg(feature = "logging")]
    app.add_plugins(
        bevy::log::LogPlugin { 
            level: bevy::log::Level::DEBUG, 
            custom_layer: |_| None, 
            filter: "wgpu=error,bevy_render=info,bevy_ecs=info".to_string(),
            fmt_layer: |_| None,
        }
    );
    
    app
}

pub fn _tick_world(app: &mut App) -> &mut App {
    app.update();
    app
}

struct AutoRunPlugin;

impl Plugin for AutoRunPlugin {
    fn build(&self, app: &mut App) {
        let timeout_seconds = option_env!("CORTEX_AUTORUN_HEARTBEAT_TIMEOUT_SECONDS")
        .map(|s| s.trim().parse::<u64>().ok()).flatten()
        .unwrap_or(60*5) // 5 mins by default
        ; 

        let period_seconds = option_env!("CORTEX_AUTORUN_PERIOD_SECONDS")
            .map(|s| s.trim().parse::<u64>().ok()).flatten()
            .unwrap_or(60*60*6) // 6 hours by default
        ; 

        app
        .init_resource::<AutoRunHeartbeatTracker>()
        .insert_resource(AutoRunHeartbeatTimeout(Duration::from_secs(timeout_seconds)))
        .insert_resource(AutoRunHeartbeatWrapPeriod(Duration::from_secs(period_seconds)))
        .add_systems(Startup, setup_wrap_period)
        .add_systems(Last, check_heartbeat_system)
        .add_observer(update_heartbeat)
        ;
    }
}

pub fn configure_for_autorun(mut app: App) -> App {
    let run_rate = option_env!("CORTEX_AUTORUN_RATE_MILISECONDS")
        .map(|s| s.trim().parse::<u64>().ok()).flatten()
        .unwrap_or(200) // 200ms by default
    ; 

    app.add_plugins((
        MinimalPlugins.set(bevy::app::ScheduleRunnerPlugin::run_loop(core::time::Duration::from_millis(run_rate))),
        AutoRunPlugin,
    ));
    app
}

pub fn autorun(mut app: App) {
    app
    .run();
}

pub fn create_and_autorun() {
    let app = configure_for_autorun(create_app());
    #[cfg(feature = "logging")]
    bevy::log::info!("Created a Cranium Server app, running...");
    autorun(app);
}