diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index fdfc59e39..57d127b6a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack index f80fe4f46..1729742f0 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_127.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack index 44637b61e..3dff2bb55 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b31m1.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack index e6b00b02a..a92c410bb 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_2b32.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 5f5479956..bf6627076 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack and b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_empty.msgpack b/client/tests/fixtures/msgpack/snapshot_empty.msgpack index fdfc59e39..57d127b6a 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_empty.msgpack and b/client/tests/fixtures/msgpack/snapshot_empty.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack index 8eb130b16..a6f1bd929 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_full.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack index d060cb1e1..ac3bd511f 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_minimal.msgpack and b/client/tests/fixtures/msgpack/snapshot_minimal.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack index 0995486e6..7b0438135 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack and b/client/tests/fixtures/msgpack/snapshot_multi_entity.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack index 186aaefd0..6e5dfc25f 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack and b/client/tests/fixtures/msgpack/snapshot_one_npc.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_player.msgpack b/client/tests/fixtures/msgpack/snapshot_player.msgpack index 5fd938d84..83093a587 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_player.msgpack and b/client/tests/fixtures/msgpack/snapshot_player.msgpack differ diff --git a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack index cdb443b2e..e7fad6a21 100644 Binary files a/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack and b/client/tests/fixtures/msgpack/snapshot_v2_full.msgpack differ diff --git a/server/Cargo.lock b/server/Cargo.lock index 9bcb90c59..26eabff95 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -226,7 +238,7 @@ dependencies = [ "critical-section", "foldhash", "futures-channel", - "hashbrown", + "hashbrown 0.16.1", "js-sys", "portable-atomic", "portable-atomic-util", @@ -371,6 +383,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -585,12 +607,30 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -686,6 +726,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -697,6 +746,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heapless" version = "0.9.2" @@ -721,7 +779,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -767,6 +825,17 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "log" version = "0.4.29" @@ -931,6 +1000,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1056,6 +1131,20 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1147,7 +1236,7 @@ dependencies = [ [[package]] name = "settled-reach-server" -version = "0.1.24" +version = "0.1.25" dependencies = [ "bevy_app", "bevy_ecs", @@ -1159,6 +1248,7 @@ dependencies = [ "rand_chacha", "rmp-serde", "ron", + "rusqlite", "serde", "serde_json", "serde_yaml", @@ -1177,6 +1267,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "slab" version = "0.4.12" @@ -1451,6 +1547,12 @@ dependencies = [ "syn", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" diff --git a/server/Cargo.toml b/server/Cargo.toml index a9cbc69f7..cc16664bd 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -21,6 +21,7 @@ clap = { version = "4", features = ["derive"] } crossbeam-channel = "0.5" sysinfo = "0.35" serde_json = "1" +rusqlite = { version = "0.32", features = ["bundled"] } [features] default = ["gauntlet"] diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index f97cc0290..b527aa1ba 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -319,6 +319,7 @@ mod tests { sim_errors: vec![], debug_response: None, current_ticker: None, + settings_response: None, } } @@ -459,6 +460,7 @@ mod tests { sim_errors: vec![], debug_response: None, current_ticker: None, + settings_response: None, }; let text = format_snapshot_text(&snap); assert!(text.contains("Tick 0")); diff --git a/server/src/settings/mod.rs b/server/src/settings/mod.rs new file mode 100644 index 000000000..c4bb253dc --- /dev/null +++ b/server/src/settings/mod.rs @@ -0,0 +1,204 @@ +// 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). + 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 } => { + let deleted = store.delete(key); + snapshot_buffer.pending_settings_response = Some(SettingsResponseWire { + success: true, + kind: "ack".into(), + settings: vec![], + error: if deleted { + None + } else { + Some(format!("setting not found: {}", key)) + }, + }); + } + } + } +} + +/// 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"); + } +} diff --git a/server/src/settings/store.rs b/server/src/settings/store.rs new file mode 100644 index 000000000..3f38e670f --- /dev/null +++ b/server/src/settings/store.rs @@ -0,0 +1,311 @@ +// SQLite settings storage backend (#627) +// Persistent key-value settings with typed columns via rusqlite (bundled). +// Per-player isolation via player_id column — multiplayer-ready (D-010). + +use rusqlite::{params, Connection}; +use std::collections::BTreeMap; +use std::path::Path; + +use super::types::{SettingEntry, SettingValue}; + +/// SQLite-backed settings store. +/// +/// Architecture: single `settings` table with typed value columns. +/// Each setting is a row keyed by (player_id, key). No JSON blobs — +/// future settings are just new rows, zero migrations. +/// +/// The `bundled` feature compiles SQLite into the binary, so there's +/// no runtime dependency on a system SQLite library. +pub struct SettingsStore { + conn: Connection, +} + +impl SettingsStore { + /// Open (or create) the settings database at `path`. + /// Creates the schema if the table doesn't exist. + pub fn open(path: &Path) -> Result { + let conn = Connection::open(path)?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA synchronous = NORMAL; + PRAGMA foreign_keys = ON;", + )?; + conn.execute( + "CREATE TABLE IF NOT EXISTS settings ( + player_id TEXT NOT NULL, + key TEXT NOT NULL, + value_type TEXT NOT NULL, + value_text TEXT, + value_int INTEGER, + value_float REAL, + value_bool INTEGER, + PRIMARY KEY (player_id, key) + )", + [], + )?; + Ok(Self { conn }) + } + + /// Open an in-memory database (for tests). + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.execute( + "CREATE TABLE settings ( + player_id TEXT NOT NULL, + key TEXT NOT NULL, + value_type TEXT NOT NULL, + value_text TEXT, + value_int INTEGER, + value_float REAL, + value_bool INTEGER, + PRIMARY KEY (player_id, key) + )", + [], + )?; + Ok(Self { conn }) + } + + /// Set a single setting for a player. Upserts (insert or replace). + pub fn set( + &self, + player_id: &str, + key: &str, + value: &SettingValue, + ) -> Result<(), rusqlite::Error> { + let (vtype, vtext, vint, vfloat, vbool) = value_to_columns(value); + self.conn.execute( + "INSERT OR REPLACE INTO settings + (player_id, key, value_type, value_text, value_int, value_float, value_bool) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![player_id, key, vtype, vtext, vint, vfloat, vbool], + )?; + Ok(()) + } + + /// Get a single setting for a player. Returns None if not found. + pub fn get(&self, player_id: &str, key: &str) -> Result, rusqlite::Error> { + let mut stmt = self.conn.prepare( + "SELECT value_type, value_text, value_int, value_float, value_bool + FROM settings WHERE player_id = ?1 AND key = ?2", + )?; + let mut rows = stmt.query(params![player_id, key])?; + match rows.next()? { + Some(row) => { + let vtype: String = row.get(0)?; + Ok(Some(columns_to_value(&vtype, row)?)) + } + None => Ok(None), + } + } + + /// Get all settings for a player as a sorted map. + pub fn get_all( + &self, + player_id: &str, + ) -> Result, rusqlite::Error> { + let mut stmt = self.conn.prepare( + "SELECT key, value_type, value_text, value_int, value_float, value_bool + FROM settings WHERE player_id = ?1 ORDER BY key", + )?; + let mut rows = stmt.query(params![player_id])?; + let mut result = BTreeMap::new(); + while let Some(row) = rows.next()? { + let key: String = row.get(0)?; + let vtype: String = row.get(1)?; + let value = columns_to_value_offset(&vtype, row)?; + result.insert(key, value); + } + Ok(result) + } + + /// Delete a single setting for a player. Returns true if a row was deleted. + pub fn delete(&self, player_id: &str, key: &str) -> Result { + let count = self.conn.execute( + "DELETE FROM settings WHERE player_id = ?1 AND key = ?2", + params![player_id, key], + )?; + Ok(count > 0) + } + + /// Get all settings as a Vec for wire serialization. + pub fn get_all_entries( + &self, + player_id: &str, + ) -> Result, rusqlite::Error> { + self.get_all(player_id).map(|map| { + map.into_iter() + .map(|(key, value)| SettingEntry { key, value }) + .collect() + }) + } +} + +/// Map a SettingValue to SQLite column values. +fn value_to_columns( + value: &SettingValue, +) -> ( + &'static str, + Option, + Option, + Option, + Option, +) { + match value { + SettingValue::String(s) => ("string", Some(s.clone()), None, None, None), + SettingValue::Int(i) => ("int", None, Some(*i), None, None), + SettingValue::Float(f) => ("float", None, None, Some(*f), None), + SettingValue::Bool(b) => ("bool", None, None, None, Some(*b)), + } +} + +/// Read a SettingValue from a row where value columns start at index 1. +/// Used by `get()` which selects (value_type, value_text, value_int, value_float, value_bool). +fn columns_to_value( + vtype: &str, + row: &rusqlite::Row<'_>, +) -> Result { + match vtype { + "string" => Ok(SettingValue::String(row.get::<_, String>(1)?)), + "int" => Ok(SettingValue::Int(row.get::<_, i64>(2)?)), + "float" => Ok(SettingValue::Float(row.get::<_, f64>(3)?)), + "bool" => Ok(SettingValue::Bool(row.get::<_, bool>(4)?)), + other => Ok(SettingValue::String(format!("", other))), + } +} + +/// Read a SettingValue from a row where value columns start at index 2. +/// Used by `get_all()` which selects (key, value_type, value_text, ...). +fn columns_to_value_offset( + vtype: &str, + row: &rusqlite::Row<'_>, +) -> Result { + match vtype { + "string" => Ok(SettingValue::String(row.get::<_, String>(2)?)), + "int" => Ok(SettingValue::Int(row.get::<_, i64>(3)?)), + "float" => Ok(SettingValue::Float(row.get::<_, f64>(4)?)), + "bool" => Ok(SettingValue::Bool(row.get::<_, bool>(5)?)), + other => Ok(SettingValue::String(format!("", other))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_store() -> SettingsStore { + SettingsStore::open_in_memory().expect("open in-memory store") + } + + #[test] + fn set_and_get_string() { + let store = test_store(); + store + .set("p1", "display.theme", &SettingValue::String("dark".into())) + .unwrap(); + let val = store.get("p1", "display.theme").unwrap(); + assert_eq!(val, Some(SettingValue::String("dark".into()))); + } + + #[test] + fn set_and_get_int() { + let store = test_store(); + store + .set("p1", "audio.volume", &SettingValue::Int(75)) + .unwrap(); + let val = store.get("p1", "audio.volume").unwrap(); + assert_eq!(val, Some(SettingValue::Int(75))); + } + + #[test] + fn set_and_get_float() { + let store = test_store(); + store + .set("p1", "audio.master", &SettingValue::Float(0.85)) + .unwrap(); + let val = store.get("p1", "audio.master").unwrap(); + assert_eq!(val, Some(SettingValue::Float(0.85))); + } + + #[test] + fn set_and_get_bool() { + let store = test_store(); + store + .set("p1", "ai_dialogue.enabled", &SettingValue::Bool(true)) + .unwrap(); + let val = store.get("p1", "ai_dialogue.enabled").unwrap(); + assert_eq!(val, Some(SettingValue::Bool(true))); + } + + #[test] + fn get_missing_returns_none() { + let store = test_store(); + let val = store.get("p1", "nonexistent").unwrap(); + assert_eq!(val, None); + } + + #[test] + fn upsert_overwrites() { + let store = test_store(); + store + .set("p1", "audio.volume", &SettingValue::Int(50)) + .unwrap(); + store + .set("p1", "audio.volume", &SettingValue::Int(80)) + .unwrap(); + let val = store.get("p1", "audio.volume").unwrap(); + assert_eq!(val, Some(SettingValue::Int(80))); + } + + #[test] + fn player_isolation() { + let store = test_store(); + store + .set("p1", "volume", &SettingValue::Int(50)) + .unwrap(); + store + .set("p2", "volume", &SettingValue::Int(90)) + .unwrap(); + assert_eq!(store.get("p1", "volume").unwrap(), Some(SettingValue::Int(50))); + assert_eq!(store.get("p2", "volume").unwrap(), Some(SettingValue::Int(90))); + } + + #[test] + fn get_all_sorted() { + let store = test_store(); + store.set("p1", "z_key", &SettingValue::Int(1)).unwrap(); + store.set("p1", "a_key", &SettingValue::Int(2)).unwrap(); + store.set("p1", "m_key", &SettingValue::Int(3)).unwrap(); + let all = store.get_all("p1").unwrap(); + let keys: Vec<&String> = all.keys().collect(); + assert_eq!(keys, vec!["a_key", "m_key", "z_key"]); + } + + #[test] + fn delete_setting() { + let store = test_store(); + store.set("p1", "key", &SettingValue::Int(1)).unwrap(); + assert!(store.delete("p1", "key").unwrap()); + assert_eq!(store.get("p1", "key").unwrap(), None); + } + + #[test] + fn delete_nonexistent_returns_false() { + let store = test_store(); + assert!(!store.delete("p1", "nope").unwrap()); + } + + #[test] + fn get_all_entries_wire_format() { + let store = test_store(); + store + .set("p1", "audio.volume", &SettingValue::Float(0.5)) + .unwrap(); + store + .set("p1", "ai_dialogue.enabled", &SettingValue::Bool(true)) + .unwrap(); + let entries = store.get_all_entries("p1").unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].key, "ai_dialogue.enabled"); + assert_eq!(entries[1].key, "audio.volume"); + } +} diff --git a/server/src/settings/types.rs b/server/src/settings/types.rs new file mode 100644 index 000000000..277fa3b46 --- /dev/null +++ b/server/src/settings/types.rs @@ -0,0 +1,117 @@ +// Settings type definitions (#627) +// Wire types for settings IPC and typed value storage. + +use serde::{Deserialize, Serialize}; + +/// Typed setting value. Extensible key-value with typed columns — +/// future settings don't require migrations. +/// +/// Wire format: MessagePack via serde, same as all bridge types. +/// SQLite mapping: each variant maps to a dedicated column in the settings table. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum SettingValue { + String(String), + Int(i64), + Float(f64), + Bool(bool), +} + +impl SettingValue { + /// Type tag for SQLite storage (value_type column). + pub fn type_tag(&self) -> &'static str { + match self { + Self::String(_) => "string", + Self::Int(_) => "int", + Self::Float(_) => "float", + Self::Bool(_) => "bool", + } + } +} + +impl std::fmt::Display for SettingValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::String(s) => write!(f, "{}", s), + Self::Int(i) => write!(f, "{}", i), + Self::Float(v) => write!(f, "{}", v), + Self::Bool(b) => write!(f, "{}", b), + } + } +} + +/// Settings response wire type included in ObserverSnapshot (#627). +/// Present for exactly one tick after a settings operation completes. +/// +/// Two kinds: +/// - `Ack`: confirms a single setting was changed. +/// - `Full`: full settings dump (response to RequestAllSettings, or initial load). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingsResponseWire { + /// Whether the operation succeeded. + pub success: bool, + /// "ack" for single-change confirmation, "full" for dump. + pub kind: String, + /// All current settings (populated for "full", single entry for "ack"). + pub settings: Vec, + /// Error message if `success` is false. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// A single key-value setting entry on the wire. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SettingEntry { + pub key: String, + pub value: SettingValue, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn setting_value_type_tags() { + assert_eq!(SettingValue::String("x".into()).type_tag(), "string"); + assert_eq!(SettingValue::Int(42).type_tag(), "int"); + assert_eq!(SettingValue::Float(1.5).type_tag(), "float"); + assert_eq!(SettingValue::Bool(true).type_tag(), "bool"); + } + + #[test] + fn setting_value_roundtrip() { + let values = vec![ + SettingValue::String("hello".into()), + SettingValue::Int(-1), + SettingValue::Float(3.14), + SettingValue::Bool(false), + ]; + for val in &values { + let bytes = rmp_serde::to_vec_named(val).expect("serialize"); + let decoded: SettingValue = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert_eq!(&decoded, val); + } + } + + #[test] + fn settings_response_wire_roundtrip() { + let resp = SettingsResponseWire { + success: true, + kind: "full".into(), + settings: vec![ + SettingEntry { + key: "audio.volume".into(), + value: SettingValue::Float(0.8), + }, + SettingEntry { + key: "ai_dialogue.enabled".into(), + value: SettingValue::Bool(true), + }, + ], + error: None, + }; + let bytes = rmp_serde::to_vec_named(&resp).expect("serialize"); + let decoded: SettingsResponseWire = rmp_serde::from_slice(&bytes).expect("deserialize"); + assert!(decoded.success); + assert_eq!(decoded.settings.len(), 2); + } +} diff --git a/server/src/simulation/input.rs b/server/src/simulation/input.rs index 126ef0f15..d88111251 100644 --- a/server/src/simulation/input.rs +++ b/server/src/simulation/input.rs @@ -4,6 +4,7 @@ use crate::bridge::debug::DebugCommandBuffer; use crate::bridge::types::{FacingDirection, ObjectType, PlayerAction, PlayerInput}; +use crate::settings::{SettingsCommand, SettingsCommandBuffer}; use crate::knowledge::{EntityRegistry, StableId}; use crate::perception::vision_cone::{facing_from_delta, Facing}; use crate::simulation::interaction::{DoorInteractRequest, DoorState, TerminalInteractRequest}; @@ -100,6 +101,7 @@ pub fn process_player_input( mut room_snapshots: Option>, mut save_load: Option>, mut debug_cmd_buffer: Option>, + mut settings_cmd_buffer: Option>, door_states: Query<&DoorState>, object_types: Query<&ObjectType>, ) { @@ -122,6 +124,9 @@ pub fn process_player_input( | PlayerAction::SaveGame { .. } | PlayerAction::LoadGame { .. } | PlayerAction::DebugCommand(_) + | PlayerAction::ChangeSetting { .. } + | PlayerAction::RequestAllSettings + | PlayerAction::DeleteSetting { .. } ) { continue; @@ -374,6 +379,27 @@ pub fn process_player_input( tracing::warn!("DebugCommand received but DebugCommandBuffer not registered"); } } + PlayerAction::ChangeSetting { key, value } => { + if let Some(ref mut buf) = settings_cmd_buffer { + buf.push(SettingsCommand::Change { key, value }); + } else { + tracing::warn!("ChangeSetting received but SettingsCommandBuffer not registered"); + } + } + PlayerAction::RequestAllSettings => { + if let Some(ref mut buf) = settings_cmd_buffer { + buf.push(SettingsCommand::RequestAll); + } else { + tracing::warn!("RequestAllSettings received but SettingsCommandBuffer not registered"); + } + } + PlayerAction::DeleteSetting { key } => { + if let Some(ref mut buf) = settings_cmd_buffer { + buf.push(SettingsCommand::Delete { key }); + } else { + tracing::warn!("DeleteSetting received but SettingsCommandBuffer not registered"); + } + } } } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index 30265f5a2..d99ade20e 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -77,6 +77,7 @@ fn snapshot_roundtrip_over_unix_socket() { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, }; bridge diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index b4a1dce8d..7ae456996 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -63,6 +63,7 @@ fn snapshot_roundtrip_over_tcp() { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, }; bridge diff --git a/server/tests/error_handling.rs b/server/tests/error_handling.rs index c529c33a8..b37b0eaab 100644 --- a/server/tests/error_handling.rs +++ b/server/tests/error_handling.rs @@ -304,6 +304,7 @@ fn snapshot_with_sim_errors_roundtrips() { }, ], current_ticker: None, + settings_response: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index ab5574cc2..854a6492f 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -53,6 +53,7 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, } } @@ -249,6 +250,7 @@ fn generate_msgpack_fixtures() { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, }; write_fixture( "snapshot_v2_full", @@ -415,6 +417,7 @@ fn generate_msgpack_fixtures() { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, }; write_fixture( "snapshot_full", diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 9ebb953d2..1d0786ec0 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -41,6 +41,7 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, } } @@ -304,6 +305,7 @@ fn snapshot_v2_fields_roundtrip() { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); @@ -358,7 +360,7 @@ fn protocol_version_constant_matches_snapshot() { let snapshot = test_snapshot(0, vec![]); assert_eq!(snapshot.version, PROTOCOL_VERSION); assert_eq!( - PROTOCOL_VERSION, 19, + PROTOCOL_VERSION, 20, "bump this assertion when protocol version changes" ); } @@ -413,6 +415,7 @@ fn all_facing_direction_variants_roundtrip() { debug_response: None, sim_errors: vec![], current_ticker: None, + settings_response: None, }; let bytes = rmp_serde::to_vec_named(&snapshot).expect("serialize"); let decoded: ObserverSnapshot = rmp_serde::from_slice(&bytes).expect("deserialize");