// Settings module (#627) // Persistent settings via SQLite on the server side. // Architecture: settings live on the SERVER in a SQLite database with per-player // tables. The client sends ChangeSettings commands over IPC, same as any other // player action. The client never touches the database directly. // // Scope: keybindings, audio volume, display preferences, accessibility options, // AI-Enhanced Dialogue toggle (#646). pub mod store; pub mod types; use bevy_app::prelude::*; use bevy_ecs::prelude::*; use std::sync::Mutex; pub use store::SettingsStore; pub use types::{SettingEntry, SettingValue, SettingsResponseWire}; /// Bevy resource wrapping the SQLite settings store. /// /// Initialized at server startup with a path derived from the save directory. /// v0.1: single player, player_id = "default". /// Multiplayer-ready: per-player isolation via player_id column (D-010). /// /// Uses Mutex because rusqlite::Connection is !Sync. The lock is held only /// for the duration of each operation (sub-millisecond for SQLite). #[derive(Resource)] pub struct SettingsStoreResource { store: Mutex, /// Player ID for settings isolation. v0.1: always "default". player_id: String, } impl SettingsStoreResource { pub fn new(store: SettingsStore, player_id: String) -> Self { Self { store: Mutex::new(store), player_id, } } /// Get a single setting. pub fn get(&self, key: &str) -> Option { let store = self.store.lock().expect("settings mutex poisoned"); match store.get(&self.player_id, key) { Ok(val) => val, Err(e) => { tracing::error!("settings get({}) failed: {}", key, e); None } } } /// Set a single setting. Returns true on success. pub fn set(&self, key: &str, value: &SettingValue) -> bool { let store = self.store.lock().expect("settings mutex poisoned"); match store.set(&self.player_id, key, value) { Ok(()) => { tracing::debug!("settings set: {} = {}", key, value); true } Err(e) => { tracing::error!("settings set({}) failed: {}", key, e); false } } } /// Delete a single setting. Returns true if it existed. pub fn delete(&self, key: &str) -> bool { let store = self.store.lock().expect("settings mutex poisoned"); match store.delete(&self.player_id, key) { Ok(deleted) => deleted, Err(e) => { tracing::error!("settings delete({}) failed: {}", key, e); false } } } /// Get all settings as wire entries. pub fn get_all_entries(&self) -> Vec { let store = self.store.lock().expect("settings mutex poisoned"); match store.get_all_entries(&self.player_id) { Ok(entries) => entries, Err(e) => { tracing::error!("settings get_all failed: {}", e); vec![] } } } } /// Buffer for pending settings commands received via IPC. /// Drained by `process_settings_commands` each tick. #[derive(Resource, Debug, Default)] pub struct SettingsCommandBuffer { commands: Vec, } impl SettingsCommandBuffer { pub fn push(&mut self, cmd: SettingsCommand) { self.commands.push(cmd); } pub fn drain(&mut self) -> Vec { std::mem::take(&mut self.commands) } } /// Settings command variants queued from PlayerAction. #[derive(Debug, Clone)] pub enum SettingsCommand { /// Change a single setting. Change { key: String, value: SettingValue }, /// Request a full settings dump. RequestAll, /// Delete a single setting (restore to default). Delete { key: String }, } /// Process settings commands from the IPC buffer. /// Reads SettingsCommandBuffer, writes to SettingsStoreResource, /// and stages a response in SnapshotBuffer.pending_settings_response. pub fn process_settings_commands( settings_store: Option>, mut cmd_buffer: ResMut, mut snapshot_buffer: ResMut, ) { let commands = cmd_buffer.drain(); if commands.is_empty() { return; } let Some(store) = settings_store else { tracing::warn!("settings commands received but SettingsStoreResource not registered"); return; }; // Process all commands, last response wins (same tick batching). // If multiple settings commands arrive in one tick, only the final response // reaches the client. This is acceptable: the client sends one action per tick // in normal use, and batch changes via RequestAll after bulk updates. for cmd in commands { match cmd { SettingsCommand::Change { ref key, ref value } => { if store.set(key, value) { snapshot_buffer.pending_settings_response = Some(SettingsResponseWire { success: true, kind: "ack".into(), settings: vec![SettingEntry { key: key.clone(), value: value.clone(), }], error: None, }); } else { snapshot_buffer.pending_settings_response = Some(SettingsResponseWire { success: false, kind: "ack".into(), settings: vec![], error: Some(format!("failed to set setting: {}", key)), }); } } SettingsCommand::RequestAll => { let entries = store.get_all_entries(); snapshot_buffer.pending_settings_response = Some(SettingsResponseWire { success: true, kind: "full".into(), settings: entries, error: None, }); } SettingsCommand::Delete { ref key } => { // Idempotent: deleting a non-existent key is a silent no-op. let _deleted = store.delete(key); snapshot_buffer.pending_settings_response = Some(SettingsResponseWire { success: true, kind: "ack".into(), settings: vec![], error: None, }); } } } } /// Settings plugin. Registers resources and the processing system. pub struct SettingsPlugin; impl Plugin for SettingsPlugin { fn build(&self, app: &mut App) { app.init_resource::().add_systems( Update, process_settings_commands .after(crate::simulation::input::process_player_input) .before(crate::perception::observer::compute_observer_snapshot), ); tracing::debug!("SettingsPlugin initialized"); } }