cloudiful-bevy-settings 0.1.0

Reusable Bevy settings runtime for app-defined actions, field keys, and localization context.
Documentation
use crate::{RequestedSettingAction, SettingActionHandler};
use bevy::{
    app::{App, Update},
    ecs::{
        message::{Message, MessageReader},
        schedule::{IntoScheduleConfigs, SystemSet},
        system::{ResMut, ScheduleSystem},
    },
    prelude::Resource,
};
use bevy_persistent::Persistent;
use serde::{Serialize, de::DeserializeOwned};

#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SettingSystemSet {
    ApplyActions,
    SyncUi,
}

pub fn apply_setting_action<T, Action, AppAction>(settings: &mut Persistent<T>, action: AppAction)
where
    T: Resource + Serialize + DeserializeOwned + SettingActionHandler<Action>,
    Action: Copy + TryFrom<AppAction>,
    AppAction: Copy,
{
    let Ok(action) = Action::try_from(action) else {
        return;
    };

    if !SettingActionHandler::can_apply(settings.get(), action) {
        return;
    }

    settings
        .update(|setting: &mut T| {
            SettingActionHandler::apply(setting, action);
        })
        .unwrap();
}

pub fn change_setting<T, Action, Requested, AppAction>(
    mut settings: ResMut<Persistent<T>>,
    mut action_messages: MessageReader<Requested>,
) where
    T: Resource + Serialize + DeserializeOwned + SettingActionHandler<Action>,
    Action: Copy + TryFrom<AppAction>,
    Requested: Message + RequestedSettingAction<AppAction>,
    AppAction: Copy,
{
    for message in action_messages.read() {
        apply_setting_action(&mut settings, message.action());
    }
}

pub fn register_setting_systems<ChangeMarker, SyncMarker>(
    app: &mut App,
    change_systems: impl IntoScheduleConfigs<ScheduleSystem, ChangeMarker>,
    sync_systems: impl IntoScheduleConfigs<ScheduleSystem, SyncMarker>,
) -> &mut App {
    app.configure_sets(
        Update,
        (
            SettingSystemSet::ApplyActions,
            SettingSystemSet::SyncUi.after(SettingSystemSet::ApplyActions),
        ),
    )
    .add_systems(
        Update,
        change_systems.in_set(SettingSystemSet::ApplyActions),
    )
    .add_systems(Update, sync_systems.in_set(SettingSystemSet::SyncUi))
}

#[cfg(test)]
mod tests {
    use super::{apply_setting_action, change_setting};
    use crate::{RequestedSettingAction, SettingActionHandler};
    use bevy::{app::App, ecs::message::Message, prelude::*};
    use bevy_persistent::{Persistent, StorageFormat};
    use serde::{Deserialize, Serialize};
    use std::{
        path::PathBuf,
        sync::atomic::{AtomicUsize, Ordering},
    };

    static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);

    #[derive(Debug, Clone, Copy, PartialEq, Eq)]
    enum TestAction {
        Increase,
        Disabled,
    }

    #[derive(Resource, Debug, Default, Serialize, Deserialize)]
    struct TestSettings {
        value: u32,
        applied: u32,
    }

    impl SettingActionHandler<TestAction> for TestSettings {
        fn can_apply(&self, action: TestAction) -> bool {
            match action {
                TestAction::Increase => self.value == 0,
                TestAction::Disabled => false,
            }
        }

        fn apply(&mut self, action: TestAction) {
            if let TestAction::Increase = action {
                self.value += 1;
                self.applied += 1;
            }
        }
    }

    #[derive(Message, Debug, Clone, Copy)]
    struct TestRequested {
        action: TestAction,
    }

    impl RequestedSettingAction<TestAction> for TestRequested {
        fn action(&self) -> TestAction {
            self.action
        }
    }

    #[test]
    fn apply_setting_action_respects_can_apply_and_updates_once() {
        let mut settings = test_persistent(TestSettings::default());

        apply_setting_action::<_, TestAction, TestAction>(&mut settings, TestAction::Disabled);
        assert_eq!(settings.get().value, 0);
        assert_eq!(settings.get().applied, 0);

        apply_setting_action::<_, TestAction, TestAction>(&mut settings, TestAction::Increase);
        assert_eq!(settings.get().value, 1);
        assert_eq!(settings.get().applied, 1);

        apply_setting_action::<_, TestAction, TestAction>(&mut settings, TestAction::Increase);
        assert_eq!(settings.get().value, 1);
        assert_eq!(settings.get().applied, 1);
    }

    #[test]
    fn change_setting_reads_action_from_requested_message() {
        let mut app = App::new();
        app.add_message::<TestRequested>();
        app.insert_resource(test_persistent(TestSettings::default()));
        app.add_systems(
            Update,
            change_setting::<TestSettings, TestAction, TestRequested, TestAction>,
        );

        app.world_mut().write_message(TestRequested {
            action: TestAction::Increase,
        });
        app.update();

        let settings = app.world().resource::<Persistent<TestSettings>>();
        assert_eq!(settings.get().value, 1);
        assert_eq!(settings.get().applied, 1);
    }

    fn test_persistent(default: TestSettings) -> Persistent<TestSettings> {
        let path = test_file_path("settings");

        Persistent::<TestSettings>::builder()
            .name("test settings")
            .format(StorageFormat::Toml)
            .path(&path)
            .default(default)
            .build()
            .unwrap_or_else(|err| panic!("failed to build persistent settings at {path:?}: {err}"))
    }

    fn test_file_path(name: &str) -> PathBuf {
        let next_id = TEST_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
        std::env::temp_dir().join(format!(
            "cloudiful-bevy-settings-{name}-{}-{next_id}.toml",
            std::process::id()
        ))
    }
}