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:
2026-03-13 09:11:53 +01:00
co-authored by Claude Opus 4.6
parent 1a4fd578cc
commit accbe579c9
24 changed files with 776 additions and 4 deletions
+2
View File
@@ -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"));
+204
View File
@@ -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");
}
}
+311
View File
@@ -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");
}
}
+117
View File
@@ -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);
}
}
+26
View File
@@ -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");
}
}
}
}