feat(simulation): SQLite settings storage with IPC protocol v20 (#627)
Per-player settings via rusqlite (bundled, zero runtime dep). Server owns the settings DB; client sends ChangeSettings commands over IPC. Extensible key-value with typed columns (String/Int/Float/Bool). Protocol bumped to v20 with settings_response in ObserverSnapshot. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Generated
+105
-3
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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<SettingsStore>,
|
||||
/// 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<SettingValue> {
|
||||
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<SettingEntry> {
|
||||
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<SettingsCommand>,
|
||||
}
|
||||
|
||||
impl SettingsCommandBuffer {
|
||||
pub fn push(&mut self, cmd: SettingsCommand) {
|
||||
self.commands.push(cmd);
|
||||
}
|
||||
|
||||
pub fn drain(&mut self) -> Vec<SettingsCommand> {
|
||||
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<Res<SettingsStoreResource>>,
|
||||
mut cmd_buffer: ResMut<SettingsCommandBuffer>,
|
||||
mut snapshot_buffer: ResMut<crate::bridge::types::SnapshotBuffer>,
|
||||
) {
|
||||
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::<SettingsCommandBuffer>()
|
||||
.add_systems(
|
||||
Update,
|
||||
process_settings_commands
|
||||
.after(crate::simulation::input::process_player_input)
|
||||
.before(crate::perception::observer::compute_observer_snapshot),
|
||||
);
|
||||
tracing::debug!("SettingsPlugin initialized");
|
||||
}
|
||||
}
|
||||
@@ -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<Self, rusqlite::Error> {
|
||||
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<Self, rusqlite::Error> {
|
||||
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<Option<SettingValue>, 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<BTreeMap<String, SettingValue>, 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<bool, rusqlite::Error> {
|
||||
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<SettingEntry> for wire serialization.
|
||||
pub fn get_all_entries(
|
||||
&self,
|
||||
player_id: &str,
|
||||
) -> Result<Vec<SettingEntry>, 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<String>,
|
||||
Option<i64>,
|
||||
Option<f64>,
|
||||
Option<bool>,
|
||||
) {
|
||||
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<SettingValue, rusqlite::Error> {
|
||||
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!("<unknown type: {}>", 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<SettingValue, rusqlite::Error> {
|
||||
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!("<unknown type: {}>", 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");
|
||||
}
|
||||
}
|
||||
@@ -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<SettingEntry>,
|
||||
/// Error message if `success` is false.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ResMut<RoomSnapshots>>,
|
||||
mut save_load: Option<ResMut<SaveLoadPending>>,
|
||||
mut debug_cmd_buffer: Option<ResMut<DebugCommandBuffer>>,
|
||||
mut settings_cmd_buffer: Option<ResMut<SettingsCommandBuffer>>,
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ fn snapshot_roundtrip_over_unix_socket() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -63,6 +63,7 @@ fn snapshot_roundtrip_over_tcp() {
|
||||
debug_response: None,
|
||||
sim_errors: vec![],
|
||||
current_ticker: None,
|
||||
settings_response: None,
|
||||
};
|
||||
|
||||
bridge
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -53,6 +53,7 @@ fn fixture_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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",
|
||||
|
||||
@@ -41,6 +41,7 @@ fn test_snapshot(tick: u64, entities: Vec<VisibleEntity>) -> 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");
|
||||
|
||||
Reference in New Issue
Block a user