//! Matomo analytics plugin — buffers game-play events and flushes them to //! the configured Matomo instance in the background. //! //! Disabled by default (opt-in via Settings → Privacy). Only active when //! `settings.analytics_enabled` is `true` AND `settings.matomo_url` is set. use std::sync::Arc; use bevy::prelude::*; use bevy::tasks::AsyncComputeTaskPool; use solitaire_core::game_state::GameMode; use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings}; use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent}; use crate::resources::{GameStateResource, TokioRuntimeResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; // --------------------------------------------------------------------------- // Resource // --------------------------------------------------------------------------- /// Holds the active Matomo client. `None` when the feature is disabled. #[derive(Resource)] pub struct AnalyticsResource { pub client: Option>, flush_timer: Timer, } impl Default for AnalyticsResource { fn default() -> Self { Self { client: None, flush_timer: Timer::from_seconds(60.0, TimerMode::Repeating), } } } // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- /// Registers analytics systems. Add after `SettingsPlugin` in the app. pub struct AnalyticsPlugin; impl Plugin for AnalyticsPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() .add_systems(Startup, init_analytics) .add_systems( Update, ( react_to_settings_change, on_game_won, on_forfeit, on_new_game, on_achievement_unlocked, tick_flush_timer, ), ); } } // --------------------------------------------------------------------------- // Systems // --------------------------------------------------------------------------- fn init_analytics(settings: Res, mut analytics: ResMut) { analytics.client = client_for(&settings.0); } fn react_to_settings_change( mut events: MessageReader, mut analytics: ResMut, ) { for ev in events.read() { analytics.client = client_for(&ev.0); } } fn on_game_won( mut wins: MessageReader, analytics: Res, rt: Res, ) { let Some(client) = analytics.client.clone() else { return; }; for ev in wins.read() { client.event("Game", "Won", None, Some(ev.score as f64)); fire_flush(client.clone(), rt.0.clone()); } } fn on_forfeit( mut forfeits: MessageReader, analytics: Res, rt: Res, ) { let Some(client) = analytics.client.clone() else { return; }; for _ev in forfeits.read() { client.event("Game", "Forfeit", None, None); fire_flush(client.clone(), rt.0.clone()); } } fn on_new_game( mut requests: MessageReader, analytics: Res, game: Res, ) { let Some(client) = analytics.client.clone() else { return; }; for ev in requests.read() { if !ev.confirmed { continue; } let mode = ev.mode.unwrap_or(game.0.mode); client.event("Game", "Start", Some(mode_str(mode)), None); } } fn on_achievement_unlocked( mut achievements: MessageReader, analytics: Res, ) { let Some(client) = analytics.client.clone() else { return; }; for ev in achievements.read() { client.event("Achievement", "Unlocked", Some(&ev.0.id), None); } } fn tick_flush_timer( time: Res