Fix all Clippy warnings across the server codebase (2411 insertions, 1341 deletions). Raise type-complexity-threshold to 750 and too-many-arguments to 12 in .clippy.toml for idiomatic Bevy ECS system signatures. The server now passes `cargo clippy -- --deny warnings` cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
307 lines
10 KiB
Rust
307 lines
10 KiB
Rust
// 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_batch("PRAGMA foreign_keys = ON;")?;
|
|
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, 1)?))
|
|
}
|
|
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(&vtype, row, 2)?;
|
|
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 `offset`.
|
|
/// `offset` is the index of the value_text column (value_int = offset+1, etc.).
|
|
///
|
|
/// Used by `get()` (offset=1) and `get_all()` (offset=2) which select different
|
|
/// column prefixes before the value columns.
|
|
fn columns_to_value(
|
|
vtype: &str,
|
|
row: &rusqlite::Row<'_>,
|
|
offset: usize,
|
|
) -> Result<SettingValue, rusqlite::Error> {
|
|
match vtype {
|
|
"string" => Ok(SettingValue::String(row.get::<_, String>(offset)?)),
|
|
"int" => Ok(SettingValue::Int(row.get::<_, i64>(offset + 1)?)),
|
|
"float" => Ok(SettingValue::Float(row.get::<_, f64>(offset + 2)?)),
|
|
"bool" => Ok(SettingValue::Bool(row.get::<_, bool>(offset + 3)?)),
|
|
other => {
|
|
tracing::warn!(
|
|
"unknown settings value_type '{}', treating as String",
|
|
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");
|
|
}
|
|
}
|