mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-12 20:11:02 +00:00
feat(portable): local canonical storage for portable characters (Phase 1)
Adds crates/coa-core/src/portable: UUIDv7 identities, namespaced content ids, the v1 portable character model (validated, bounded, canonical), compressed hashed snapshots, a union-only collection set, and a SQLite (WAL) store with revisions, stale-revision rejection, rollback-as-new-revision, configurable history, realm/item mappings and forward-only migrations. No realm database, network or UI is touched; no existing behaviour changes. 45 new tests. Format documented in docs/PORTABLE_FORMAT.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e84763f14
commit
efd4b8bbf2
15 files changed
+3039
-1
No files matched your search
Generated
+82
@@ -502,6 +502,7 @@ dependencies = [
|
||||
"num-bigint",
|
||||
"rand_core",
|
||||
"reqwest 0.12.28",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
@@ -1100,6 +1101,18 @@ 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.5.0"
|
||||
@@ -1613,11 +1626,32 @@ version = "0.12.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
|
||||
dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
@@ -2244,6 +2278,17 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.38.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
@@ -3294,6 +3339,31 @@ dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsqlite-vfs"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.40.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
"sqlite-wasm-rs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.3"
|
||||
@@ -3869,6 +3939,18 @@ dependencies = [
|
||||
"der",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlite-wasm-rs"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"js-sys",
|
||||
"rsqlite-vfs",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@@ -10,7 +10,7 @@ serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
thiserror = "2"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
uuid = { version = "1", features = ["v4", "v7", "serde"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||
dunce = "1"
|
||||
fs4 = "0.13"
|
||||
@@ -25,6 +25,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
sha1 = "0.10"
|
||||
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
|
||||
tempfile = "3"
|
||||
rusqlite = { version = "0.40.2", features = ["bundled"] }
|
||||
|
||||
[dev-dependencies]
|
||||
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod population;
|
||||
pub mod process;
|
||||
pub mod manifest;
|
||||
pub mod migrations;
|
||||
pub mod portable;
|
||||
pub mod multiworld;
|
||||
pub mod repair;
|
||||
pub mod schema_check;
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Account-wide collections (wardrobe, vanity, mounts, pets, ...): sets of numeric ids that only ever **grow**.
|
||||
//!
|
||||
//! * `canonical = canonical U incoming`: an id is never removed because another realm did not return it.
|
||||
//! * Compact form: sorted ids, delta + LEB128 varint, ~1-2 bytes per id; never a verbose JSON array.
|
||||
//! * `collection_hash` makes "did anything change?" a 32-byte comparison; when it is equal nothing is transferred.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::error::{PortableError, Result};
|
||||
use super::versions::PORTABLE_COLLECTION_FORMAT_VERSION;
|
||||
|
||||
/// Largest number of ids in one collection (a realm's whole wardrobe is a few thousand).
|
||||
pub const MAX_SET_IDS: usize = 2_000_000;
|
||||
/// Largest encoded size accepted when decoding.
|
||||
pub const MAX_ENCODED_BYTES: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// Collection kinds are namespaced names such as `coa:wardrobe`, `coa:vanity`, `mod:foo:mounts`.
|
||||
pub fn valid_kind(kind: &str) -> bool {
|
||||
super::ids::valid_namespace(kind) && kind.contains(':')
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Default)]
|
||||
pub struct IdSet(Vec<u32>);
|
||||
|
||||
impl IdSet {
|
||||
pub fn new() -> Self {
|
||||
Self(Vec::new())
|
||||
}
|
||||
|
||||
/// Build from any ids: sorted and de-duplicated.
|
||||
pub fn from_ids(ids: impl IntoIterator<Item = u32>) -> Result<Self> {
|
||||
let mut v: Vec<u32> = ids.into_iter().collect();
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
if v.len() > MAX_SET_IDS {
|
||||
return Err(PortableError::LimitExceeded(format!("a collection cannot hold more than {MAX_SET_IDS} ids")));
|
||||
}
|
||||
Ok(Self(v))
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
pub fn contains(&self, id: u32) -> bool {
|
||||
self.0.binary_search(&id).is_ok()
|
||||
}
|
||||
pub fn ids(&self) -> &[u32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// `self U other`.
|
||||
pub fn union(&self, other: &IdSet) -> IdSet {
|
||||
let (a, b) = (&self.0, &other.0);
|
||||
let mut out = Vec::with_capacity(a.len() + b.len());
|
||||
let (mut i, mut j) = (0, 0);
|
||||
while i < a.len() && j < b.len() {
|
||||
match a[i].cmp(&b[j]) {
|
||||
std::cmp::Ordering::Less => {
|
||||
out.push(a[i]);
|
||||
i += 1;
|
||||
}
|
||||
std::cmp::Ordering::Greater => {
|
||||
out.push(b[j]);
|
||||
j += 1;
|
||||
}
|
||||
std::cmp::Ordering::Equal => {
|
||||
out.push(a[i]);
|
||||
i += 1;
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&a[i..]);
|
||||
out.extend_from_slice(&b[j..]);
|
||||
IdSet(out)
|
||||
}
|
||||
|
||||
/// How many ids of `other` are not in `self`.
|
||||
pub fn count_new(&self, other: &IdSet) -> usize {
|
||||
other.0.iter().filter(|id| !self.contains(**id)).count()
|
||||
}
|
||||
|
||||
/// `[format version][count varint][first id varint][gap varint ...]` where every gap is the difference to the
|
||||
/// previous id (always at least 1).
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(2 + self.0.len() * 2);
|
||||
out.push(PORTABLE_COLLECTION_FORMAT_VERSION as u8);
|
||||
put_varint(&mut out, self.0.len() as u64);
|
||||
let mut previous = 0u32;
|
||||
for (index, id) in self.0.iter().enumerate() {
|
||||
put_varint(&mut out, if index == 0 { *id as u64 } else { (*id - previous) as u64 });
|
||||
previous = *id;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decode(bytes: &[u8]) -> Result<IdSet> {
|
||||
if bytes.len() > MAX_ENCODED_BYTES {
|
||||
return Err(PortableError::LimitExceeded("an encoded collection is too large".into()));
|
||||
}
|
||||
let bad = |what: &str| PortableError::CorruptSnapshot(format!("collection: {what}"));
|
||||
let (&version, mut rest) = bytes.split_first().ok_or_else(|| bad("empty"))?;
|
||||
if version as u32 > PORTABLE_COLLECTION_FORMAT_VERSION {
|
||||
return Err(PortableError::UnsupportedFormat { found: version as u32, supported: PORTABLE_COLLECTION_FORMAT_VERSION });
|
||||
}
|
||||
if version == 0 {
|
||||
return Err(bad("version 0"));
|
||||
}
|
||||
let count = take_varint(&mut rest).ok_or_else(|| bad("truncated count"))?;
|
||||
if count > MAX_SET_IDS as u64 {
|
||||
return Err(PortableError::LimitExceeded(format!("a collection cannot hold more than {MAX_SET_IDS} ids")));
|
||||
}
|
||||
let mut ids = Vec::with_capacity(count as usize);
|
||||
let mut previous = 0u64;
|
||||
for index in 0..count {
|
||||
let step = take_varint(&mut rest).ok_or_else(|| bad("truncated ids"))?;
|
||||
let id = if index == 0 {
|
||||
step
|
||||
} else {
|
||||
if step == 0 {
|
||||
return Err(bad("ids are not strictly ascending"));
|
||||
}
|
||||
previous.checked_add(step).ok_or_else(|| bad("overflow"))?
|
||||
};
|
||||
if id > u32::MAX as u64 {
|
||||
return Err(bad("id out of range"));
|
||||
}
|
||||
ids.push(id as u32);
|
||||
previous = id;
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
return Err(bad("trailing bytes"));
|
||||
}
|
||||
Ok(IdSet(ids))
|
||||
}
|
||||
|
||||
/// Hash of one collection: binds the kind and the exact content.
|
||||
pub fn hash(&self, kind: &str) -> [u8; 32] {
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"coa-collection-v1\0");
|
||||
h.update(kind.as_bytes());
|
||||
h.update([0]);
|
||||
h.update(self.encode());
|
||||
h.finalize().into()
|
||||
}
|
||||
}
|
||||
|
||||
fn put_varint(out: &mut Vec<u8>, mut v: u64) {
|
||||
while v >= 0x80 {
|
||||
out.push((v as u8 & 0x7F) | 0x80);
|
||||
v >>= 7;
|
||||
}
|
||||
out.push(v as u8);
|
||||
}
|
||||
|
||||
fn take_varint(rest: &mut &[u8]) -> Option<u64> {
|
||||
let mut value = 0u64;
|
||||
for shift in (0..64).step_by(7) {
|
||||
let (&byte, tail) = rest.split_first()?;
|
||||
*rest = tail;
|
||||
value |= ((byte & 0x7F) as u64) << shift;
|
||||
if byte & 0x80 == 0 {
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pseudo_random_ids(n: usize, spread: u32) -> Vec<u32> {
|
||||
// deterministic xorshift, no dependency
|
||||
let mut x = 0x9E37_79B9u32;
|
||||
(0..n)
|
||||
.map(|_| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x % spread
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_and_size_at_realistic_and_extreme_sizes() {
|
||||
for (count, spread) in [(0usize, 1u32), (1, 10), (2_000, 40_000), (10_000, 200_000), (50_000, 1_000_000)] {
|
||||
let set = IdSet::from_ids(pseudo_random_ids(count, spread)).unwrap();
|
||||
let encoded = set.encode();
|
||||
assert_eq!(IdSet::decode(&encoded).unwrap(), set);
|
||||
// a compact binary form: well under 3 bytes per id even for scattered ids, never a JSON array
|
||||
assert!(encoded.len() <= 8 + set.len() * 3, "{} ids -> {} bytes", set.len(), encoded.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_never_removes_and_is_idempotent() {
|
||||
let a = IdSet::from_ids([5, 1, 9, 9]).unwrap();
|
||||
let b = IdSet::from_ids([2, 9, 11]).unwrap();
|
||||
let u = a.union(&b);
|
||||
assert_eq!(u.ids(), &[1, 2, 5, 9, 11]);
|
||||
assert!(a.ids().iter().all(|id| u.contains(*id)), "nothing of the old set may disappear");
|
||||
assert_eq!(u.union(&b), u);
|
||||
assert_eq!(u.union(&IdSet::new()), u);
|
||||
assert_eq!(a.count_new(&b), 2);
|
||||
assert_eq!(u.count_new(&a), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_changes_with_content_and_kind() {
|
||||
let a = IdSet::from_ids([1, 2, 3]).unwrap();
|
||||
let b = IdSet::from_ids([1, 2, 4]).unwrap();
|
||||
assert_eq!(a.hash("coa:wardrobe"), IdSet::from_ids([3, 2, 1]).unwrap().hash("coa:wardrobe"));
|
||||
assert_ne!(a.hash("coa:wardrobe"), b.hash("coa:wardrobe"));
|
||||
assert_ne!(a.hash("coa:wardrobe"), a.hash("coa:vanity"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_encodings_are_refused() {
|
||||
let good = IdSet::from_ids([10, 20, 30]).unwrap().encode();
|
||||
assert!(IdSet::decode(&[]).is_err());
|
||||
assert!(IdSet::decode(&good[..good.len() - 1]).is_err(), "truncated");
|
||||
let mut trailing = good.clone();
|
||||
trailing.push(0);
|
||||
assert!(IdSet::decode(&trailing).is_err());
|
||||
assert!(IdSet::decode(&[1, 3, 10, 0, 5]).is_err(), "a zero gap means a duplicate");
|
||||
assert!(IdSet::decode(&[9, 0]).is_err(), "newer format");
|
||||
assert!(IdSet::decode(&[0, 0]).is_err(), "version zero");
|
||||
// count far beyond the limit
|
||||
let mut huge = vec![1];
|
||||
put_varint(&mut huge, MAX_SET_IDS as u64 + 1);
|
||||
assert!(matches!(IdSet::decode(&huge), Err(PortableError::LimitExceeded(_))));
|
||||
// an id above u32
|
||||
let mut big = vec![1, 1];
|
||||
put_varint(&mut big, u32::MAX as u64 + 1);
|
||||
assert!(IdSet::decode(&big).is_err());
|
||||
// an over-long varint must not loop or overflow
|
||||
assert!(IdSet::decode(&[1, 1, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kinds_are_namespaced() {
|
||||
assert!(valid_kind("coa:wardrobe"));
|
||||
assert!(valid_kind("mod:foo:mounts"));
|
||||
assert!(!valid_kind("wardrobe"));
|
||||
assert!(!valid_kind("Coa:Wardrobe"));
|
||||
assert!(!valid_kind(""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use super::ids::CharacterId;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PortableError>;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PortableError {
|
||||
#[error("database: {0}")]
|
||||
Sqlite(#[from] rusqlite::Error),
|
||||
#[error("io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("json: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("character {0} already exists")]
|
||||
DuplicateCharacter(CharacterId),
|
||||
#[error("character {0} does not exist")]
|
||||
UnknownCharacter(CharacterId),
|
||||
#[error("revision {revision} of character {character} does not exist (it may have been pruned)")]
|
||||
UnknownRevision { character: CharacterId, revision: u64 },
|
||||
#[error("stale revision: the caller expected {expected} but the canonical revision is {current}")]
|
||||
StaleRevision { expected: u64, current: u64 },
|
||||
#[error("a character's ruleset cannot change ({from} -> {to})")]
|
||||
RulesetChange { from: String, to: String },
|
||||
#[error("the snapshot belongs to character {found}, not {expected}")]
|
||||
WrongCharacter { expected: CharacterId, found: CharacterId },
|
||||
|
||||
#[error("local character {local_guid} on server {server_id} is already bound to another portable character")]
|
||||
LocalGuidTaken { server_id: String, local_guid: u32 },
|
||||
#[error("local item {local_item_guid} on server {server_id} is mapped twice for one character")]
|
||||
ItemGuidConflict { server_id: String, local_item_guid: u32 },
|
||||
|
||||
#[error("invalid data: {0}")]
|
||||
Invalid(String),
|
||||
#[error("limit exceeded: {0}")]
|
||||
LimitExceeded(String),
|
||||
#[error("stored snapshot is damaged: {0}")]
|
||||
CorruptSnapshot(String),
|
||||
#[error("data format version {found} is newer than the supported version {supported}")]
|
||||
UnsupportedFormat { found: u32, supported: u32 },
|
||||
#[error("the portable database was written by a newer Manager (schema {found}, this Manager understands {supported})")]
|
||||
NewerDatabase { found: i64, supported: i64 },
|
||||
#[error("this file is not a Manager portable database")]
|
||||
NotPortableDatabase,
|
||||
}
|
||||
|
||||
impl From<PortableError> for crate::Error {
|
||||
fn from(e: PortableError) -> Self {
|
||||
crate::Error::Invalid(e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Deterministic test characters. Ids are fixed UUIDv7 values so two calls build equal characters.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::ids::{CharacterId, ContentId, PortableItemId, PortablePetId};
|
||||
use super::model::*;
|
||||
use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
|
||||
|
||||
pub fn id7(n: u64) -> Uuid {
|
||||
let mut random = [0u8; 10];
|
||||
random[..8].copy_from_slice(&n.to_be_bytes());
|
||||
uuid::Builder::from_unix_timestamp_millis(1_790_000_000_000 + n, &random).into_uuid()
|
||||
}
|
||||
|
||||
pub fn character_id(n: u64) -> CharacterId {
|
||||
CharacterId::from_uuid(id7(n)).unwrap()
|
||||
}
|
||||
|
||||
fn item_id(n: u64) -> PortableItemId {
|
||||
PortableItemId::from_uuid(id7(10_000 + n)).unwrap()
|
||||
}
|
||||
|
||||
fn content(kind: &str, id: u64) -> ContentId {
|
||||
ContentId::new("coa", kind, id).unwrap()
|
||||
}
|
||||
|
||||
fn empty_progression(level: u8) -> Progression {
|
||||
Progression {
|
||||
level,
|
||||
xp: 0,
|
||||
money: 0,
|
||||
honor: Honor { arena_points: 0, total_honor: 0, today_honor: 0, yesterday_honor: 0, total_kills: 0, today_kills: 0, yesterday_kills: 0 },
|
||||
known_currencies: 0,
|
||||
chosen_title: 0,
|
||||
known_titles: "0 0 0 0 0 0".into(),
|
||||
explored_zones: "0 0 0 0".into(),
|
||||
taxi_mask: "0 0 0 0".into(),
|
||||
bank_slots: 0,
|
||||
stable_slots: 0,
|
||||
watched_faction: 0,
|
||||
action_bars_mask: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn empty_build() -> Build {
|
||||
Build {
|
||||
spells: Vec::new(),
|
||||
talents: Vec::new(),
|
||||
skills: Vec::new(),
|
||||
glyphs: Vec::new(),
|
||||
talent_groups_count: 1,
|
||||
active_talent_group: 0,
|
||||
extra_bonus_talent_count: 0,
|
||||
reset_talents_cost: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn naked_level_one() -> PortableCharacter {
|
||||
PortableCharacter {
|
||||
format_version: PORTABLE_CHARACTER_FORMAT_VERSION,
|
||||
character_id: character_id(1),
|
||||
ruleset: Ruleset::Coa,
|
||||
content_namespace: "coa".into(),
|
||||
identity: Identity {
|
||||
name: "Newbie".into(),
|
||||
race: content("race", 1),
|
||||
class: content("class", 12),
|
||||
gender: 0,
|
||||
appearance: Appearance { skin: 1, face: 2, hair_style: 3, hair_color: 4, facial_style: 0 },
|
||||
cosmetic_flags: 0,
|
||||
},
|
||||
progression: empty_progression(1),
|
||||
build: empty_build(),
|
||||
items: Vec::new(),
|
||||
quests: Quests { active: Vec::new(), rewarded: Vec::new() },
|
||||
reputation: Vec::new(),
|
||||
actions: Vec::new(),
|
||||
pets: Vec::new(),
|
||||
settings: BTreeMap::new(),
|
||||
client_data: BTreeMap::new(),
|
||||
extensions: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn simple_item(n: u64, container: Option<PortableItemId>, slot: u8, entry: u64, count: u32) -> PortableItem {
|
||||
PortableItem {
|
||||
id: item_id(n),
|
||||
container,
|
||||
slot,
|
||||
entry: content("item", entry),
|
||||
count,
|
||||
duration: 0,
|
||||
charges: vec![0, 0, 0, 0, 0],
|
||||
flags: 1,
|
||||
enchantments: Vec::new(),
|
||||
random_property_id: 0,
|
||||
durability: 100,
|
||||
played_time: 3600,
|
||||
text: None,
|
||||
creator_name: None,
|
||||
gift: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A level 80 character shaped like the real data of the audit: ~1,300 spells, 105 reputations, 23 skills,
|
||||
/// equipment, four bags with contents, enchants, gems, a pet, CoA settings and macros.
|
||||
pub fn geared_level_eighty() -> PortableCharacter {
|
||||
let mut c = naked_level_one();
|
||||
c.character_id = character_id(2);
|
||||
c.identity.name = "Geared".into();
|
||||
c.identity.class = content("class", 21);
|
||||
c.progression = empty_progression(80);
|
||||
c.progression.xp = 12_345;
|
||||
c.progression.money = 200_060_523;
|
||||
c.progression.honor.total_honor = 4092;
|
||||
c.progression.honor.arena_points = 150;
|
||||
c.progression.known_currencies = 0b1010_0101;
|
||||
c.progression.chosen_title = 5;
|
||||
c.progression.explored_zones = (0..128).map(|i| (i * 7919u32).to_string()).collect::<Vec<_>>().join(" ");
|
||||
c.progression.bank_slots = 4;
|
||||
|
||||
c.build.spells = (1..=1284u32).map(|n| (500_000 + n * 3, if n % 5 == 0 { 3 } else { 1 })).collect();
|
||||
c.build.skills = (1..=23u32).map(|n| Skill { skill: n * 11, value: 300 + n as u16, max: 450 }).collect();
|
||||
c.build.glyphs = vec![GlyphSet { talent_group: 0, glyphs: [1, 2, 3, 4, 5, 6] }];
|
||||
c.build.extra_bonus_talent_count = 2;
|
||||
|
||||
// equipment 0..18
|
||||
let mut n = 0u64;
|
||||
let mut equipped_ids = Vec::new();
|
||||
for slot in 0..19u8 {
|
||||
n += 1;
|
||||
let mut item = simple_item(n, None, slot, 30_000 + n * 17, 1);
|
||||
item.flags = 1;
|
||||
item.durability = 90 + slot as u32;
|
||||
if slot % 3 == 0 {
|
||||
item.enchantments = vec![
|
||||
Enchantment { slot: 0, id: 3_000 + slot as u32, duration: 0, charges: 0 },
|
||||
Enchantment { slot: 2, id: 3_878, duration: 0, charges: 0 },
|
||||
Enchantment { slot: 3, id: 3_879, duration: 0, charges: 0 },
|
||||
];
|
||||
}
|
||||
if slot == 5 {
|
||||
item.random_property_id = -57;
|
||||
item.creator_name = Some("Crafter".into());
|
||||
}
|
||||
equipped_ids.push(item.id);
|
||||
c.items.push(item);
|
||||
}
|
||||
// four bags (slots 19..22), each with six items
|
||||
for bag_slot in 19..23u8 {
|
||||
n += 1;
|
||||
let bag = simple_item(n, None, bag_slot, 41_000 + bag_slot as u64, 1);
|
||||
let bag_id = bag.id;
|
||||
c.items.push(bag);
|
||||
for slot in 0..6u8 {
|
||||
n += 1;
|
||||
c.items.push(simple_item(n, Some(bag_id), slot, 50_000 + n, 1 + (slot as u32 % 3) * 4));
|
||||
}
|
||||
}
|
||||
// backpack
|
||||
for slot in 23..31u8 {
|
||||
n += 1;
|
||||
c.items.push(simple_item(n, None, slot, 60_000 + n, 20));
|
||||
}
|
||||
// bank bag with a book that has text, and a currency token
|
||||
n += 1;
|
||||
let mut book = simple_item(n, None, 40, 70_001, 1);
|
||||
book.text = Some("A very short book.\nSecond line.".into());
|
||||
c.items.push(book);
|
||||
n += 1;
|
||||
let mut token = simple_item(n, None, 118, 375_250, 12);
|
||||
token.flags = 0;
|
||||
c.items.push(token);
|
||||
n += 1;
|
||||
let mut wrapped = simple_item(n, None, 41, 5_042, 1);
|
||||
wrapped.gift = Some(Gift { entry: content("item", 8_000), flags: 8 });
|
||||
c.items.push(wrapped);
|
||||
|
||||
c.quests.active = vec![
|
||||
ActiveQuest { quest: 12_001, status: 3, explored: false, timer: 0, mob_counts: [3, 0, 0, 0], item_counts: [0; 6], player_count: 0 },
|
||||
ActiveQuest { quest: 12_002, status: 1, explored: true, timer: 600, mob_counts: [0; 4], item_counts: [2, 0, 0, 0, 0, 0], player_count: 0 },
|
||||
];
|
||||
c.quests.rewarded = vec![12_000, 12_003, 12_004, 99_001];
|
||||
c.reputation = (1..=105u32).map(|f| ReputationEntry { faction: f * 3, standing: (f as i32) * 100 - 3_000, flags: if f % 7 == 0 { 17 } else { 1 } }).collect();
|
||||
c.actions = (0..12u8).map(|b| ActionButton { spec: 0, button: b, action: 500_003 + b as u32 * 3, kind: 0 }).collect();
|
||||
|
||||
c.pets = vec![PortablePet {
|
||||
id: PortablePetId::from_uuid(id7(20_000)).unwrap(),
|
||||
entry: content("creature", 416),
|
||||
model_id: 4_449,
|
||||
created_by_spell: 688,
|
||||
pet_type: 0,
|
||||
level: 80,
|
||||
exp: 1_000,
|
||||
react_state: 1,
|
||||
name: "Fuzzy".into(),
|
||||
renamed: true,
|
||||
slot: 0,
|
||||
health: 5_000,
|
||||
mana: 800,
|
||||
happiness: 0,
|
||||
action_bar: "7 2 0 0 1 0 1 1 0".into(),
|
||||
spells: vec![PetSpell { spell: 3110, active: 1 }, PetSpell { spell: 6307, active: 0 }],
|
||||
declined_names: None,
|
||||
}];
|
||||
|
||||
c.settings.insert("core.ascension_active_spec".into(), vec![54]);
|
||||
c.settings.insert("core.ascension_starter".into(), vec![1]);
|
||||
c.settings.insert("core.ascension_build.54".into(), vec![3, 56_710, 56_320, 90_101]);
|
||||
c.settings.insert("core.ascension_bar.54".into(), vec![2, 0, 500_003, 1, 500_006]);
|
||||
c.client_data.insert(5, ClientBlob { time: 1_790_000_000, data: Bytes(b"macro data \x00\x01\x02 with binary".to_vec()) });
|
||||
c.normalized()
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
//! Identifiers. Everything that must be globally unique is a UUIDv7 (time-ordered, generated locally, never an
|
||||
//! auto-increment). Numeric ids of game content are never global: they live in a namespace.
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::error::{PortableError, Result};
|
||||
|
||||
macro_rules! uuid7_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
$(#[$meta])*
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct $name(Uuid);
|
||||
|
||||
impl $name {
|
||||
/// A new, time-ordered id.
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::now_v7())
|
||||
}
|
||||
|
||||
/// Wrap an existing UUID. Only version-7 ids are accepted: an id from outside this system that is not
|
||||
/// time-ordered is a sign of a foreign or forged source.
|
||||
pub fn from_uuid(uuid: Uuid) -> Result<Self> {
|
||||
if uuid.get_version_num() != 7 || uuid.get_variant() != uuid::Variant::RFC4122 {
|
||||
return Err(PortableError::Invalid(format!("{} is not a UUIDv7", uuid)));
|
||||
}
|
||||
Ok(Self(uuid))
|
||||
}
|
||||
|
||||
pub fn as_uuid(&self) -> Uuid {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for $name {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0.hyphenated())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for $name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}({})", stringify!($name), self.0.hyphenated())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for $name {
|
||||
type Err = PortableError;
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
let uuid = Uuid::parse_str(s).map_err(|_| PortableError::Invalid(format!("{s:?} is not a UUID")))?;
|
||||
Self::from_uuid(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for $name {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let text = String::deserialize(d)?;
|
||||
text.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
uuid7_id!(
|
||||
/// The global identity of a portable character. Unlike the local AzerothCore guid it is the same on every realm.
|
||||
CharacterId
|
||||
);
|
||||
uuid7_id!(
|
||||
/// The owner's profile (their characters and collections).
|
||||
ProfileId
|
||||
);
|
||||
uuid7_id!(
|
||||
/// The global identity of one item of a portable character. Local item guids differ per realm; this does not.
|
||||
PortableItemId
|
||||
);
|
||||
uuid7_id!(
|
||||
/// The global identity of one pet of a portable character.
|
||||
PortablePetId
|
||||
);
|
||||
|
||||
/// A reference to a piece of game content that cannot be mistaken for another namespace's numbering:
|
||||
/// `core:wotlk:item:19019`, `coa:wardrobe:10581`, `mod:<module>:item:7`.
|
||||
///
|
||||
/// The textual form is `<namespace>:<kind>:<id>` where the namespace may itself contain colons; the last two
|
||||
/// segments are always the kind and the numeric id.
|
||||
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct ContentId {
|
||||
namespace: String,
|
||||
kind: String,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
const MAX_SEGMENT: usize = 48;
|
||||
|
||||
fn valid_segment(s: &str) -> bool {
|
||||
!s.is_empty() && s.len() <= MAX_SEGMENT && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-' || b == b'.')
|
||||
}
|
||||
|
||||
/// A namespace is one or more segments joined by colons.
|
||||
pub fn valid_namespace(ns: &str) -> bool {
|
||||
ns.len() <= 96 && ns.split(':').all(valid_segment)
|
||||
}
|
||||
|
||||
impl ContentId {
|
||||
pub fn new(namespace: &str, kind: &str, id: u64) -> Result<Self> {
|
||||
if !valid_namespace(namespace) || !valid_segment(kind) {
|
||||
return Err(PortableError::Invalid(format!("invalid content id {namespace}:{kind}:{id}")));
|
||||
}
|
||||
Ok(Self { namespace: namespace.to_string(), kind: kind.to_string(), id })
|
||||
}
|
||||
|
||||
pub fn namespace(&self) -> &str {
|
||||
&self.namespace
|
||||
}
|
||||
pub fn kind(&self) -> &str {
|
||||
&self.kind
|
||||
}
|
||||
pub fn id(&self) -> u64 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ContentId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}:{}:{}", self.namespace, self.kind, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for ContentId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "ContentId({self})")
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ContentId {
|
||||
type Err = PortableError;
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
let bad = || PortableError::Invalid(format!("{s:?} is not a content id (namespace:kind:id)"));
|
||||
let (rest, id) = s.rsplit_once(':').ok_or_else(bad)?;
|
||||
let (namespace, kind) = rest.rsplit_once(':').ok_or_else(bad)?;
|
||||
// `u64::from_str` accepts a leading '+', which would make two spellings of one id.
|
||||
if id.is_empty() || !id.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let id: u64 = id.parse().map_err(|_| bad())?;
|
||||
Self::new(namespace, kind, id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ContentId {
|
||||
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
s.collect_str(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ContentId {
|
||||
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
let text = String::deserialize(d)?;
|
||||
text.parse().map_err(de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_are_uuid_v7_and_strictly_time_ordered() {
|
||||
let ids: Vec<CharacterId> = (0..2000).map(|_| CharacterId::new()).collect();
|
||||
assert!(ids.iter().all(|id| id.as_uuid().get_version_num() == 7));
|
||||
assert!(ids.windows(2).all(|w| w[0] < w[1]), "ids generated in sequence must sort in sequence");
|
||||
let mut unique = ids.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), ids.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_uuid_v7_is_accepted_from_text() {
|
||||
let good = CharacterId::new();
|
||||
assert_eq!(good.to_string().parse::<CharacterId>().unwrap(), good);
|
||||
assert!(Uuid::new_v4().to_string().parse::<CharacterId>().is_err(), "a v4 UUID is not a portable id");
|
||||
assert!(Uuid::nil().to_string().parse::<CharacterId>().is_err());
|
||||
assert!("not-a-uuid".parse::<CharacterId>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_ids_keep_their_namespace() {
|
||||
for text in ["core:wotlk:item:19019", "coa:wardrobe:10581", "mod:craftsmans-codex:item:7", "coa:class:12"] {
|
||||
let id: ContentId = text.parse().unwrap();
|
||||
assert_eq!(id.to_string(), text);
|
||||
}
|
||||
let id: ContentId = "core:wotlk:item:19019".parse().unwrap();
|
||||
assert_eq!((id.namespace(), id.kind(), id.id()), ("core:wotlk", "item", 19019));
|
||||
let other: ContentId = "mod:x:item:19019".parse().unwrap();
|
||||
assert_ne!(id, other, "the same number in another namespace is a different thing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_content_ids_are_refused() {
|
||||
for text in ["", "item:1", "a:b", "a:b:c", "a:b:-1", "a:b:+1", "A:b:1", "a b:c:1", "a::1", ":b:1", "a:b:18446744073709551616"] {
|
||||
assert!(text.parse::<ContentId>().is_err(), "{text:?} must not parse");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_serialise_as_plain_strings() {
|
||||
let id = CharacterId::new();
|
||||
let json = serde_json::to_string(&id).unwrap();
|
||||
assert_eq!(json, format!("\"{id}\""));
|
||||
assert_eq!(serde_json::from_str::<CharacterId>(&json).unwrap(), id);
|
||||
assert!(serde_json::from_str::<CharacterId>(&format!("\"{}\"", Uuid::new_v4())).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
-- Portable store, schema 1. Local to this Manager; realm databases are never touched.
|
||||
|
||||
CREATE TABLE profile (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL,
|
||||
format_version INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE character (
|
||||
character_id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL REFERENCES profile(profile_id),
|
||||
ruleset TEXT NOT NULL CHECK (ruleset IN ('coa', 'wildcard')),
|
||||
name TEXT NOT NULL,
|
||||
race TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
gender INTEGER NOT NULL,
|
||||
level INTEGER NOT NULL,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))
|
||||
) STRICT;
|
||||
CREATE INDEX character_profile ON character(profile_id);
|
||||
|
||||
-- One row per kept revision. The newest row is the canonical state (character.revision points at it).
|
||||
CREATE TABLE snapshot (
|
||||
character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE,
|
||||
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||
snapshot_format_version INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
source_server_id TEXT NOT NULL,
|
||||
content_hash BLOB NOT NULL CHECK (length(content_hash) = 32),
|
||||
uncompressed_size INTEGER NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
note TEXT,
|
||||
PRIMARY KEY (character_id, revision)
|
||||
) STRICT, WITHOUT ROWID;
|
||||
|
||||
-- Which local guid a portable character has on which realm. Internal lookups (checkpoints, imports) go through
|
||||
-- (server_id, local_guid): a local guid belongs to at most one portable character.
|
||||
CREATE TABLE character_server_mapping (
|
||||
character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL,
|
||||
local_guid INTEGER NOT NULL,
|
||||
last_revision INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (character_id, server_id),
|
||||
UNIQUE (server_id, local_guid)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE item_mapping (
|
||||
character_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
portable_item_id TEXT NOT NULL,
|
||||
local_item_guid INTEGER NOT NULL,
|
||||
PRIMARY KEY (character_id, server_id, portable_item_id),
|
||||
UNIQUE (character_id, server_id, local_item_guid),
|
||||
FOREIGN KEY (character_id, server_id) REFERENCES character_server_mapping(character_id, server_id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE collection (
|
||||
profile_id TEXT NOT NULL REFERENCES profile(profile_id),
|
||||
kind TEXT NOT NULL,
|
||||
collection_revision INTEGER NOT NULL CHECK (collection_revision >= 1),
|
||||
collection_hash BLOB NOT NULL CHECK (length(collection_hash) = 32),
|
||||
format_version INTEGER NOT NULL,
|
||||
item_count INTEGER NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
PRIMARY KEY (profile_id, kind)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE setting (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) STRICT;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Portable characters: the local canonical storage of Phase 1.
|
||||
//!
|
||||
//! A portable character is owned by this Manager, not by any realm. Realm databases only ever hold temporary working
|
||||
//! copies. This module contains only the Manager-side model and store; it does not read or write any realm database
|
||||
//! (that is Phase 2 onwards) and knows nothing about the network.
|
||||
//!
|
||||
//! See `docs/PORTABLE_CHARACTERS_AUDIT.md` for the reasoning and `docs/PORTABLE_FORMAT.md` for the format rules.
|
||||
|
||||
pub mod collection;
|
||||
pub mod error;
|
||||
pub mod ids;
|
||||
pub mod model;
|
||||
pub mod snapshot;
|
||||
pub mod store;
|
||||
pub mod versions;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod fixtures;
|
||||
|
||||
pub use collection::IdSet;
|
||||
pub use error::{PortableError, Result};
|
||||
pub use ids::{CharacterId, ContentId, PortableItemId, PortablePetId, ProfileId};
|
||||
pub use model::{PortableCharacter, Ruleset};
|
||||
pub use store::Store;
|
||||
@@ -0,0 +1,640 @@
|
||||
//! The portable character: a *semantic* description of what moves between realms (see
|
||||
//! `docs/PORTABLE_CHARACTERS_AUDIT.md`, sections 5-7). No database row, guid or position of any realm appears here:
|
||||
//! local guids live in the Manager's mapping tables, and every item/pet carries a global UUIDv7 instead.
|
||||
//!
|
||||
//! Rules:
|
||||
//! * Unknown fields are refused (`deny_unknown_fields`); a change of shape bumps `format_version`.
|
||||
//! * Numeric ids of game content other than race/class/item/pet entries (spells, quests, factions, skills, ...) are
|
||||
//! interpreted in `content_namespace`; race, class and the entries of items/pets carry their own namespace.
|
||||
//! * Data of modules this Manager does not understand is kept as opaque [`Extension`]s and is never dropped.
|
||||
//! * Everything is bounded (`limits`), because a snapshot may come from another person's Manager.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::error::{PortableError, Result};
|
||||
use super::ids::{valid_namespace, CharacterId, ContentId, PortableItemId, PortablePetId};
|
||||
use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
|
||||
|
||||
pub mod limits {
|
||||
pub const MAX_NAME_CHARS: usize = 25;
|
||||
pub const MAX_PET_NAME_CHARS: usize = 21;
|
||||
pub const MAX_ITEMS: usize = 1_500;
|
||||
pub const MAX_SLOT: u8 = 149;
|
||||
pub const MAX_ENCHANT_SLOTS: u8 = 12;
|
||||
pub const MAX_CHARGES: usize = 5;
|
||||
pub const MAX_ITEM_TEXT_BYTES: usize = 16 * 1024;
|
||||
pub const MAX_SPELLS: usize = 20_000;
|
||||
pub const MAX_TALENTS: usize = 4_096;
|
||||
pub const MAX_SKILLS: usize = 512;
|
||||
pub const MAX_GLYPH_SETS: usize = 8;
|
||||
pub const MAX_REPUTATIONS: usize = 2_048;
|
||||
pub const MAX_ACTIVE_QUESTS: usize = 512;
|
||||
pub const MAX_REWARDED_QUESTS: usize = 50_000;
|
||||
pub const MAX_ACTIONS: usize = 1_024;
|
||||
pub const MAX_PETS: usize = 64;
|
||||
pub const MAX_PET_SPELLS: usize = 512;
|
||||
pub const MAX_SETTINGS: usize = 4_096;
|
||||
pub const MAX_SETTING_VALUES: usize = 4_096;
|
||||
pub const MAX_SETTING_SOURCE_BYTES: usize = 128;
|
||||
pub const MAX_CLIENT_BLOBS: usize = 8;
|
||||
pub const MAX_CLIENT_BLOB_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_RAW_TEXT_BYTES: usize = 8 * 1024;
|
||||
pub const MAX_EXTENSIONS: usize = 64;
|
||||
pub const MAX_EXTENSION_BYTES: usize = 1024 * 1024;
|
||||
pub const MAX_EXTENSIONS_TOTAL_BYTES: usize = 4 * 1024 * 1024;
|
||||
/// What the realm itself allows (`MAX_MONEY_AMOUNT`, `Player.h`).
|
||||
pub const MAX_MONEY: u32 = 0x7FFF_FFFF - 1;
|
||||
/// Largest decompressed snapshot accepted (decompression-bomb guard).
|
||||
pub const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024;
|
||||
/// Largest compressed snapshot accepted.
|
||||
pub const MAX_COMPRESSED_BYTES: usize = 8 * 1024 * 1024;
|
||||
}
|
||||
|
||||
use limits::*;
|
||||
|
||||
/// A character belongs to exactly one ruleset for its whole life; CoA and Wildcard never mix.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Ruleset {
|
||||
Coa,
|
||||
Wildcard,
|
||||
}
|
||||
|
||||
impl Ruleset {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Ruleset::Coa => "coa",
|
||||
Ruleset::Wildcard => "wildcard",
|
||||
}
|
||||
}
|
||||
pub fn parse(s: &str) -> Result<Self> {
|
||||
match s {
|
||||
"coa" => Ok(Ruleset::Coa),
|
||||
"wildcard" => Ok(Ruleset::Wildcard),
|
||||
other => Err(PortableError::Invalid(format!("unknown ruleset {other:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Ruleset {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Binary data as base64 text in the serialised form.
|
||||
#[derive(Clone, PartialEq, Eq, Default)]
|
||||
pub struct Bytes(pub Vec<u8>);
|
||||
|
||||
impl std::fmt::Debug for Bytes {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Bytes({} bytes)", self.0.len())
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Bytes {
|
||||
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
use base64::Engine;
|
||||
s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Bytes {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
|
||||
use base64::Engine;
|
||||
let text = String::deserialize(d)?;
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(text.as_bytes())
|
||||
.map(Bytes)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Appearance {
|
||||
pub skin: u8,
|
||||
pub face: u8,
|
||||
pub hair_style: u8,
|
||||
pub hair_color: u8,
|
||||
pub facial_style: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Identity {
|
||||
pub name: String,
|
||||
pub race: ContentId,
|
||||
pub class: ContentId,
|
||||
pub gender: u8,
|
||||
pub appearance: Appearance,
|
||||
/// Only the cosmetic bits of `playerFlags` (helm/cloak visibility); never GM/ghost/AFK bits.
|
||||
pub cosmetic_flags: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Honor {
|
||||
pub arena_points: u32,
|
||||
pub total_honor: u32,
|
||||
pub today_honor: u32,
|
||||
pub yesterday_honor: u32,
|
||||
pub total_kills: u32,
|
||||
pub today_kills: u16,
|
||||
pub yesterday_kills: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Progression {
|
||||
pub level: u8,
|
||||
pub xp: u32,
|
||||
/// Copper.
|
||||
pub money: u32,
|
||||
pub honor: Honor,
|
||||
pub known_currencies: u64,
|
||||
pub chosen_title: u32,
|
||||
/// Raw `knownTitles` text of the realm (space separated integers).
|
||||
pub known_titles: String,
|
||||
/// Raw `exploredZones` text.
|
||||
pub explored_zones: String,
|
||||
/// Raw `taximask` text.
|
||||
pub taxi_mask: String,
|
||||
pub bank_slots: u8,
|
||||
pub stable_slots: u8,
|
||||
pub watched_faction: u32,
|
||||
/// Which action bars are shown (bitmask); not the bars themselves.
|
||||
pub action_bars_mask: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Skill {
|
||||
pub skill: u32,
|
||||
pub value: u16,
|
||||
pub max: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GlyphSet {
|
||||
pub talent_group: u8,
|
||||
pub glyphs: [u16; 6],
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Build {
|
||||
/// `(spell id, spec mask)`, ascending by spell id. CoA talents are spells, so this is also the talent state.
|
||||
pub spells: Vec<(u32, u8)>,
|
||||
/// Stock talents `(spell id, spec mask)`; empty on CoA, kept for stock-shaped rulesets.
|
||||
pub talents: Vec<(u32, u8)>,
|
||||
pub skills: Vec<Skill>,
|
||||
pub glyphs: Vec<GlyphSet>,
|
||||
pub talent_groups_count: u8,
|
||||
pub active_talent_group: u8,
|
||||
pub extra_bonus_talent_count: i32,
|
||||
pub reset_talents_cost: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Enchantment {
|
||||
/// 0..12, the realm's enchantment slot.
|
||||
pub slot: u8,
|
||||
pub id: u32,
|
||||
pub duration: u32,
|
||||
pub charges: u32,
|
||||
}
|
||||
|
||||
/// A wrapped gift: what is inside the wrapper.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Gift {
|
||||
pub entry: ContentId,
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortableItem {
|
||||
pub id: PortableItemId,
|
||||
/// The bag this item is in; `None` = directly on the character (equipment, bag slots, backpack, bank, keyring,
|
||||
/// currency tokens). A container must itself be directly on the character.
|
||||
pub container: Option<PortableItemId>,
|
||||
pub slot: u8,
|
||||
pub entry: ContentId,
|
||||
pub count: u32,
|
||||
pub duration: i32,
|
||||
/// Spell charges, at most five.
|
||||
pub charges: Vec<i32>,
|
||||
pub flags: u32,
|
||||
/// Only the non-empty slots, ascending by slot.
|
||||
pub enchantments: Vec<Enchantment>,
|
||||
/// Negative = suffix.
|
||||
pub random_property_id: i32,
|
||||
pub durability: u32,
|
||||
pub played_time: u32,
|
||||
pub text: Option<String>,
|
||||
/// Display name of the crafter; the crafter's guid is never carried (it names a character of another realm).
|
||||
pub creator_name: Option<String>,
|
||||
pub gift: Option<Gift>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActiveQuest {
|
||||
pub quest: u32,
|
||||
pub status: u8,
|
||||
pub explored: bool,
|
||||
pub timer: u32,
|
||||
pub mob_counts: [u16; 4],
|
||||
pub item_counts: [u16; 6],
|
||||
pub player_count: u16,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Quests {
|
||||
pub active: Vec<ActiveQuest>,
|
||||
/// Completed quest ids, ascending. Daily/weekly/monthly/seasonal state is realm-clock state and is not carried.
|
||||
pub rewarded: Vec<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ReputationEntry {
|
||||
pub faction: u32,
|
||||
pub standing: i32,
|
||||
pub flags: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionButton {
|
||||
pub spec: u8,
|
||||
pub button: u8,
|
||||
pub action: u32,
|
||||
pub kind: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PetSpell {
|
||||
pub spell: u32,
|
||||
pub active: u8,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortablePet {
|
||||
pub id: PortablePetId,
|
||||
pub entry: ContentId,
|
||||
pub model_id: u32,
|
||||
pub created_by_spell: u32,
|
||||
/// 0 = summon pet, 1 = hunter pet.
|
||||
pub pet_type: u8,
|
||||
pub level: u16,
|
||||
pub exp: u32,
|
||||
pub react_state: u8,
|
||||
pub name: String,
|
||||
pub renamed: bool,
|
||||
/// The realm's slot: current / stable 1-4 / not in slot.
|
||||
pub slot: u8,
|
||||
pub health: u32,
|
||||
pub mana: u32,
|
||||
pub happiness: u32,
|
||||
pub action_bar: String,
|
||||
/// Ascending by spell id.
|
||||
pub spells: Vec<PetSpell>,
|
||||
/// The five declined-name forms (ruRU) when present.
|
||||
pub declined_names: Option<[String; 5]>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ClientBlob {
|
||||
pub time: u32,
|
||||
pub data: Bytes,
|
||||
}
|
||||
|
||||
/// Data of a module this Manager may not understand. It is stored and returned byte for byte.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Extension {
|
||||
pub module_version: String,
|
||||
pub format_version: u32,
|
||||
/// Lower-case hex SHA-256 of `payload`; a mismatch means the blob was corrupted.
|
||||
pub content_hash: String,
|
||||
pub payload: Bytes,
|
||||
}
|
||||
|
||||
impl Extension {
|
||||
pub fn new(module_version: &str, format_version: u32, payload: Vec<u8>) -> Self {
|
||||
Self {
|
||||
module_version: module_version.to_string(),
|
||||
format_version,
|
||||
content_hash: hex::encode(Sha256::digest(&payload)),
|
||||
payload: Bytes(payload),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_intact(&self) -> bool {
|
||||
self.content_hash == hex::encode(Sha256::digest(&self.payload.0))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortableCharacter {
|
||||
pub format_version: u32,
|
||||
pub character_id: CharacterId,
|
||||
pub ruleset: Ruleset,
|
||||
/// Namespace of the plain numeric ids below (spells, quests, factions, skills, glyphs).
|
||||
pub content_namespace: String,
|
||||
pub identity: Identity,
|
||||
pub progression: Progression,
|
||||
pub build: Build,
|
||||
pub items: Vec<PortableItem>,
|
||||
pub quests: Quests,
|
||||
pub reputation: Vec<ReputationEntry>,
|
||||
pub actions: Vec<ActionButton>,
|
||||
pub pets: Vec<PortablePet>,
|
||||
/// CoA gameplay state (`character_settings`): source -> integer vector. Only allow-listed sources are carried
|
||||
/// (policy lives with the exporter).
|
||||
pub settings: BTreeMap<String, Vec<u32>>,
|
||||
/// Per-character client blobs by account-data type (macros = 5).
|
||||
pub client_data: BTreeMap<u8, ClientBlob>,
|
||||
/// Module data by extension namespace (`mod:<module>`), kept even when unsupported by a realm.
|
||||
pub extensions: BTreeMap<String, Extension>,
|
||||
}
|
||||
|
||||
fn invalid<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(PortableError::Invalid(msg.into()))
|
||||
}
|
||||
|
||||
fn too_many<T>(what: &str, max: usize) -> Result<T> {
|
||||
Err(PortableError::LimitExceeded(format!("more than {max} {what}")))
|
||||
}
|
||||
|
||||
fn check_text(what: &str, s: &str, max_bytes: usize) -> Result<()> {
|
||||
if s.len() > max_bytes {
|
||||
return Err(PortableError::LimitExceeded(format!("{what} is longer than {max_bytes} bytes")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_name(what: &str, s: &str, max_chars: usize) -> Result<()> {
|
||||
let chars = s.chars().count();
|
||||
if chars == 0 || chars > max_chars {
|
||||
return invalid(format!("{what} must have 1 to {max_chars} characters"));
|
||||
}
|
||||
if s.chars().any(|c| c.is_control()) || s.trim() != s {
|
||||
return invalid(format!("{what} contains control characters or surrounding spaces"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ascending_unique<T: Ord + Copy>(what: &str, ids: impl Iterator<Item = T>) -> Result<()> {
|
||||
let mut previous: Option<T> = None;
|
||||
for id in ids {
|
||||
if previous.is_some_and(|p| p >= id) {
|
||||
return invalid(format!("{what} must be strictly ascending (call normalize first)"));
|
||||
}
|
||||
previous = Some(id);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PortableCharacter {
|
||||
/// Canonical order: everything that is a set is sorted and de-duplicated, so equal characters serialise to equal
|
||||
/// bytes (and therefore to equal hashes).
|
||||
pub fn normalized(mut self) -> Self {
|
||||
self.normalize();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn normalize(&mut self) {
|
||||
fn merge_masks(list: &mut Vec<(u32, u8)>) {
|
||||
list.sort_by_key(|e| e.0);
|
||||
let mut out: Vec<(u32, u8)> = Vec::with_capacity(list.len());
|
||||
for (id, mask) in list.drain(..) {
|
||||
match out.last_mut() {
|
||||
Some(last) if last.0 == id => last.1 |= mask,
|
||||
_ => out.push((id, mask)),
|
||||
}
|
||||
}
|
||||
*list = out;
|
||||
}
|
||||
merge_masks(&mut self.build.spells);
|
||||
merge_masks(&mut self.build.talents);
|
||||
self.build.skills.sort_by_key(|s| s.skill);
|
||||
self.build.skills.dedup_by_key(|s| s.skill);
|
||||
self.build.glyphs.sort_by_key(|g| g.talent_group);
|
||||
self.build.glyphs.dedup_by_key(|g| g.talent_group);
|
||||
self.items.sort_by(|a, b| {
|
||||
let key = |i: &PortableItem| (i.container.is_some(), i.container.map(|c| c.as_uuid()), i.slot, i.id.as_uuid());
|
||||
key(a).cmp(&key(b))
|
||||
});
|
||||
for item in &mut self.items {
|
||||
item.enchantments.sort_by_key(|e| e.slot);
|
||||
}
|
||||
self.quests.active.sort_by_key(|q| q.quest);
|
||||
self.quests.rewarded.sort_unstable();
|
||||
self.quests.rewarded.dedup();
|
||||
self.reputation.sort_by_key(|r| r.faction);
|
||||
self.actions.sort_by_key(|a| (a.spec, a.button));
|
||||
self.pets.sort_by_key(|p| (p.slot, p.id.as_uuid()));
|
||||
for pet in &mut self.pets {
|
||||
pet.spells.sort_by_key(|s| s.spell);
|
||||
}
|
||||
}
|
||||
|
||||
/// Structural validation. Run on everything that enters the store or comes from outside.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.format_version != PORTABLE_CHARACTER_FORMAT_VERSION {
|
||||
return Err(PortableError::UnsupportedFormat { found: self.format_version, supported: PORTABLE_CHARACTER_FORMAT_VERSION });
|
||||
}
|
||||
if !valid_namespace(&self.content_namespace) {
|
||||
return invalid("content_namespace is not a valid namespace");
|
||||
}
|
||||
|
||||
// identity
|
||||
check_name("character name", &self.identity.name, MAX_NAME_CHARS)?;
|
||||
if self.identity.gender > 2 {
|
||||
return invalid("gender must be 0, 1 or 2");
|
||||
}
|
||||
|
||||
// progression
|
||||
let p = &self.progression;
|
||||
if p.level == 0 {
|
||||
return invalid("level must be at least 1");
|
||||
}
|
||||
if p.money > MAX_MONEY {
|
||||
return invalid(format!("money exceeds the realm limit of {MAX_MONEY} copper"));
|
||||
}
|
||||
for (what, text) in [("known_titles", &p.known_titles), ("explored_zones", &p.explored_zones), ("taxi_mask", &p.taxi_mask)] {
|
||||
check_text(what, text, MAX_RAW_TEXT_BYTES)?;
|
||||
if !text.bytes().all(|b| b.is_ascii_digit() || b == b' ') {
|
||||
return invalid(format!("{what} may only contain digits and spaces"));
|
||||
}
|
||||
}
|
||||
|
||||
// build
|
||||
let b = &self.build;
|
||||
if b.spells.len() > MAX_SPELLS {
|
||||
return too_many("spells", MAX_SPELLS);
|
||||
}
|
||||
if b.talents.len() > MAX_TALENTS {
|
||||
return too_many("talents", MAX_TALENTS);
|
||||
}
|
||||
if b.skills.len() > MAX_SKILLS {
|
||||
return too_many("skills", MAX_SKILLS);
|
||||
}
|
||||
if b.glyphs.len() > MAX_GLYPH_SETS {
|
||||
return too_many("glyph sets", MAX_GLYPH_SETS);
|
||||
}
|
||||
ascending_unique("spells", b.spells.iter().map(|e| e.0))?;
|
||||
ascending_unique("talents", b.talents.iter().map(|e| e.0))?;
|
||||
ascending_unique("skills", b.skills.iter().map(|e| e.skill))?;
|
||||
ascending_unique("glyph sets", b.glyphs.iter().map(|e| e.talent_group))?;
|
||||
|
||||
self.validate_items()?;
|
||||
|
||||
// quests, reputation, actions
|
||||
if self.quests.active.len() > MAX_ACTIVE_QUESTS {
|
||||
return too_many("active quests", MAX_ACTIVE_QUESTS);
|
||||
}
|
||||
if self.quests.rewarded.len() > MAX_REWARDED_QUESTS {
|
||||
return too_many("completed quests", MAX_REWARDED_QUESTS);
|
||||
}
|
||||
ascending_unique("active quests", self.quests.active.iter().map(|q| q.quest))?;
|
||||
ascending_unique("completed quests", self.quests.rewarded.iter().copied())?;
|
||||
if self.reputation.len() > MAX_REPUTATIONS {
|
||||
return too_many("reputations", MAX_REPUTATIONS);
|
||||
}
|
||||
ascending_unique("reputations", self.reputation.iter().map(|r| r.faction))?;
|
||||
if self.actions.len() > MAX_ACTIONS {
|
||||
return too_many("action buttons", MAX_ACTIONS);
|
||||
}
|
||||
ascending_unique("action buttons", self.actions.iter().map(|a| (a.spec, a.button)))?;
|
||||
|
||||
// pets
|
||||
if self.pets.len() > MAX_PETS {
|
||||
return too_many("pets", MAX_PETS);
|
||||
}
|
||||
let mut pet_ids = HashSet::new();
|
||||
for pet in &self.pets {
|
||||
if !pet_ids.insert(pet.id) {
|
||||
return invalid("two pets have the same id");
|
||||
}
|
||||
check_name("pet name", &pet.name, MAX_PET_NAME_CHARS)?;
|
||||
check_text("pet action bar", &pet.action_bar, MAX_RAW_TEXT_BYTES)?;
|
||||
if pet.spells.len() > MAX_PET_SPELLS {
|
||||
return too_many("pet spells", MAX_PET_SPELLS);
|
||||
}
|
||||
ascending_unique("pet spells", pet.spells.iter().map(|s| s.spell))?;
|
||||
if let Some(names) = &pet.declined_names {
|
||||
for n in names {
|
||||
check_text("pet declined name", n, 128)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settings, client blobs, extensions
|
||||
if self.settings.len() > MAX_SETTINGS {
|
||||
return too_many("settings sources", MAX_SETTINGS);
|
||||
}
|
||||
for (source, values) in &self.settings {
|
||||
if source.is_empty() || source.len() > MAX_SETTING_SOURCE_BYTES || !source.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-:".contains(&c)) {
|
||||
return invalid(format!("invalid settings source {source:?}"));
|
||||
}
|
||||
if values.len() > MAX_SETTING_VALUES {
|
||||
return too_many("values in one settings source", MAX_SETTING_VALUES);
|
||||
}
|
||||
}
|
||||
if self.client_data.len() > MAX_CLIENT_BLOBS {
|
||||
return too_many("client data blobs", MAX_CLIENT_BLOBS);
|
||||
}
|
||||
for blob in self.client_data.values() {
|
||||
if blob.data.0.len() > MAX_CLIENT_BLOB_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("a client data blob is larger than {MAX_CLIENT_BLOB_BYTES} bytes")));
|
||||
}
|
||||
}
|
||||
if self.extensions.len() > MAX_EXTENSIONS {
|
||||
return too_many("extensions", MAX_EXTENSIONS);
|
||||
}
|
||||
let mut total = 0usize;
|
||||
for (namespace, ext) in &self.extensions {
|
||||
if !valid_namespace(namespace) {
|
||||
return invalid(format!("invalid extension namespace {namespace:?}"));
|
||||
}
|
||||
if ext.format_version == 0 || ext.module_version.is_empty() || ext.module_version.len() > 64 {
|
||||
return invalid(format!("extension {namespace} has an invalid version"));
|
||||
}
|
||||
if ext.payload.0.len() > MAX_EXTENSION_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("extension {namespace} is larger than {MAX_EXTENSION_BYTES} bytes")));
|
||||
}
|
||||
if !ext.is_intact() {
|
||||
return Err(PortableError::CorruptSnapshot(format!("extension {namespace} does not match its content hash")));
|
||||
}
|
||||
total += ext.payload.0.len();
|
||||
}
|
||||
if total > MAX_EXTENSIONS_TOTAL_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("extensions are larger than {MAX_EXTENSIONS_TOTAL_BYTES} bytes together")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_items(&self) -> Result<()> {
|
||||
if self.items.len() > MAX_ITEMS {
|
||||
return too_many("items", MAX_ITEMS);
|
||||
}
|
||||
let mut by_id: HashMap<PortableItemId, &PortableItem> = HashMap::with_capacity(self.items.len());
|
||||
for item in &self.items {
|
||||
if by_id.insert(item.id, item).is_some() {
|
||||
return invalid("two items have the same id");
|
||||
}
|
||||
}
|
||||
let mut places: BTreeSet<(Option<PortableItemId>, u8)> = BTreeSet::new();
|
||||
for item in &self.items {
|
||||
if item.slot > MAX_SLOT {
|
||||
return invalid(format!("item slot {} is out of range", item.slot));
|
||||
}
|
||||
if item.count == 0 {
|
||||
return invalid("an item stack cannot be empty");
|
||||
}
|
||||
if item.charges.len() > MAX_CHARGES {
|
||||
return too_many("charges on one item", MAX_CHARGES);
|
||||
}
|
||||
if let Some(text) = &item.text {
|
||||
check_text("item text", text, MAX_ITEM_TEXT_BYTES)?;
|
||||
}
|
||||
if let Some(name) = &item.creator_name {
|
||||
check_name("creator name", name, MAX_NAME_CHARS)?;
|
||||
}
|
||||
let mut last_slot: Option<u8> = None;
|
||||
for e in &item.enchantments {
|
||||
if e.slot >= MAX_ENCHANT_SLOTS || last_slot.is_some_and(|l| l >= e.slot) {
|
||||
return invalid("enchantment slots must be ascending and below 12");
|
||||
}
|
||||
last_slot = Some(e.slot);
|
||||
}
|
||||
if let Some(container) = item.container {
|
||||
match by_id.get(&container) {
|
||||
None => return invalid("an item is in a container that is not part of the character"),
|
||||
Some(c) if c.container.is_some() => return invalid("a container must be directly on the character"),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
if !places.insert((item.container, item.slot)) {
|
||||
return invalid("two items occupy the same place");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Snapshot encoding: canonical JSON -> SHA-256 -> zstd.
|
||||
//!
|
||||
//! * The **content hash** is the SHA-256 of the canonical (normalised) JSON, i.e. of the data, not of the compressed
|
||||
//! bytes, so it does not depend on the compressor version.
|
||||
//! * Decoding is hostile-input safe: compressed and decompressed sizes are capped *while reading*
|
||||
//! (decompression bombs), the format version is checked before the shape, unknown fields are refused, and the
|
||||
//! hash and every structural limit are verified.
|
||||
|
||||
use std::io::Read;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::error::{PortableError, Result};
|
||||
use super::model::{limits::*, PortableCharacter};
|
||||
use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
|
||||
|
||||
const ZSTD_LEVEL: i32 = 3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EncodedSnapshot {
|
||||
pub content_hash: [u8; 32],
|
||||
pub uncompressed_size: u64,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn hash_hex(hash: &[u8; 32]) -> String {
|
||||
hex::encode(hash)
|
||||
}
|
||||
|
||||
/// The canonical JSON of a character (normalised first).
|
||||
pub fn canonical_json(model: &PortableCharacter) -> Result<Vec<u8>> {
|
||||
let normalized = model.clone().normalized();
|
||||
let json = serde_json::to_vec(&normalized)?;
|
||||
if json.len() > MAX_SNAPSHOT_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("the snapshot is larger than {MAX_SNAPSHOT_BYTES} bytes")));
|
||||
}
|
||||
Ok(json)
|
||||
}
|
||||
|
||||
pub fn content_hash(model: &PortableCharacter) -> Result<[u8; 32]> {
|
||||
Ok(Sha256::digest(canonical_json(model)?).into())
|
||||
}
|
||||
|
||||
/// Validate, then encode.
|
||||
pub fn encode(model: &PortableCharacter) -> Result<EncodedSnapshot> {
|
||||
let model = model.clone().normalized();
|
||||
model.validate()?;
|
||||
let json = canonical_json(&model)?;
|
||||
let content_hash: [u8; 32] = Sha256::digest(&json).into();
|
||||
let payload = zstd::stream::encode_all(json.as_slice(), ZSTD_LEVEL)?;
|
||||
if payload.len() > MAX_COMPRESSED_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("the compressed snapshot is larger than {MAX_COMPRESSED_BYTES} bytes")));
|
||||
}
|
||||
Ok(EncodedSnapshot { content_hash, uncompressed_size: json.len() as u64, payload })
|
||||
}
|
||||
|
||||
/// Decode and fully verify. `expected_hash` is the hash recorded next to the payload.
|
||||
pub fn decode(payload: &[u8], expected_hash: Option<&[u8; 32]>) -> Result<PortableCharacter> {
|
||||
if payload.len() > MAX_COMPRESSED_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("the compressed snapshot is larger than {MAX_COMPRESSED_BYTES} bytes")));
|
||||
}
|
||||
let decoder = zstd::stream::read::Decoder::new(payload).map_err(|e| PortableError::CorruptSnapshot(format!("not a compressed snapshot: {e}")))?;
|
||||
let mut json = Vec::new();
|
||||
// Read one byte more than allowed: if it is there, the payload would have inflated past the cap.
|
||||
decoder
|
||||
.take(MAX_SNAPSHOT_BYTES as u64 + 1)
|
||||
.read_to_end(&mut json)
|
||||
.map_err(|e| PortableError::CorruptSnapshot(format!("decompression failed: {e}")))?;
|
||||
if json.len() > MAX_SNAPSHOT_BYTES {
|
||||
return Err(PortableError::LimitExceeded(format!("the snapshot inflates beyond {MAX_SNAPSHOT_BYTES} bytes")));
|
||||
}
|
||||
if let Some(expected) = expected_hash {
|
||||
let actual: [u8; 32] = Sha256::digest(&json).into();
|
||||
if &actual != expected {
|
||||
return Err(PortableError::CorruptSnapshot("content hash mismatch".into()));
|
||||
}
|
||||
}
|
||||
decode_json(&json)
|
||||
}
|
||||
|
||||
/// Decode canonical JSON (already decompressed and, if needed, hash-checked).
|
||||
pub fn decode_json(json: &[u8]) -> Result<PortableCharacter> {
|
||||
let value: serde_json::Value = serde_json::from_slice(json)?;
|
||||
let version = value
|
||||
.get("format_version")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or_else(|| PortableError::CorruptSnapshot("format_version is missing".into()))?;
|
||||
if version > PORTABLE_CHARACTER_FORMAT_VERSION as u64 {
|
||||
return Err(PortableError::UnsupportedFormat { found: version.min(u32::MAX as u64) as u32, supported: PORTABLE_CHARACTER_FORMAT_VERSION });
|
||||
}
|
||||
let model: PortableCharacter = serde_json::from_value(value)?;
|
||||
model.validate()?;
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::portable::fixtures;
|
||||
|
||||
#[test]
|
||||
fn roundtrip_is_structurally_equal() {
|
||||
for model in [fixtures::naked_level_one(), fixtures::geared_level_eighty()] {
|
||||
let model = model.normalized();
|
||||
let encoded = encode(&model).unwrap();
|
||||
let back = decode(&encoded.payload, Some(&encoded.content_hash)).unwrap();
|
||||
assert_eq!(back, model);
|
||||
assert_eq!(content_hash(&back).unwrap(), encoded.content_hash);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_input_is_canonicalised_before_hashing() {
|
||||
let mut shuffled = fixtures::geared_level_eighty();
|
||||
shuffled.build.spells.reverse();
|
||||
shuffled.reputation.reverse();
|
||||
shuffled.items.reverse();
|
||||
let sorted = fixtures::geared_level_eighty();
|
||||
assert_eq!(content_hash(&shuffled).unwrap(), content_hash(&sorted).unwrap());
|
||||
assert_eq!(encode(&shuffled).unwrap().payload, encode(&sorted).unwrap().payload);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_is_stable_and_sensitive() {
|
||||
let a = fixtures::geared_level_eighty();
|
||||
let mut b = a.clone();
|
||||
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
b.progression.money += 1;
|
||||
assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_extension_survives_byte_for_byte() {
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
let payload: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
|
||||
model.extensions.insert("mod:unknown-module".into(), crate::portable::model::Extension::new("9.9.9", 7, payload.clone()));
|
||||
let encoded = encode(&model).unwrap();
|
||||
let back = decode(&encoded.payload, Some(&encoded.content_hash)).unwrap();
|
||||
let ext = &back.extensions["mod:unknown-module"];
|
||||
assert_eq!(ext.payload.0, payload);
|
||||
assert_eq!((ext.module_version.as_str(), ext.format_version), ("9.9.9", 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupted_extension_is_refused() {
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
let mut ext = crate::portable::model::Extension::new("1", 1, vec![1, 2, 3]);
|
||||
ext.payload.0[0] ^= 0xFF;
|
||||
model.extensions.insert("mod:broken".into(), ext);
|
||||
assert!(matches!(encode(&model), Err(PortableError::CorruptSnapshot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_hash_is_refused() {
|
||||
let encoded = encode(&fixtures::geared_level_eighty()).unwrap();
|
||||
let mut wrong = encoded.content_hash;
|
||||
wrong[0] ^= 1;
|
||||
assert!(matches!(decode(&encoded.payload, Some(&wrong)), Err(PortableError::CorruptSnapshot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_and_truncation_are_refused() {
|
||||
assert!(decode(b"definitely not zstd", None).is_err());
|
||||
let encoded = encode(&fixtures::geared_level_eighty()).unwrap();
|
||||
assert!(decode(&encoded.payload[..encoded.payload.len() / 2], None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decompression_bomb_is_stopped_at_the_cap() {
|
||||
// 20 MiB of spaces compress to a few KB; the decoder must refuse instead of inflating it.
|
||||
let bomb = zstd::stream::encode_all(vec![b' '; MAX_SNAPSHOT_BYTES + 4 * 1024 * 1024].as_slice(), 19).unwrap();
|
||||
assert!(bomb.len() < 4096, "the test needs a genuinely small payload, got {}", bomb.len());
|
||||
assert!(matches!(decode(&bomb, None), Err(PortableError::LimitExceeded(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newer_format_is_refused_before_the_shape_is_checked() {
|
||||
let mut value = serde_json::to_value(fixtures::geared_level_eighty()).unwrap();
|
||||
value["format_version"] = serde_json::json!(PORTABLE_CHARACTER_FORMAT_VERSION + 1);
|
||||
value["a_field_of_the_future"] = serde_json::json!(true);
|
||||
let json = serde_json::to_vec(&value).unwrap();
|
||||
assert!(matches!(decode_json(&json), Err(PortableError::UnsupportedFormat { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_fields_in_the_current_format_are_refused() {
|
||||
let mut value = serde_json::to_value(fixtures::geared_level_eighty()).unwrap();
|
||||
value["identity"]["secret_flag"] = serde_json::json!(1);
|
||||
assert!(decode_json(&serde_json::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_content_is_refused() {
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
model.build.spells = (1..=(MAX_SPELLS as u32 + 1)).map(|s| (s, 1)).collect();
|
||||
assert!(matches!(encode(&model), Err(PortableError::LimitExceeded(_))));
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
model.items[0].text = Some("x".repeat(MAX_ITEM_TEXT_BYTES + 1));
|
||||
assert!(matches!(encode(&model), Err(PortableError::LimitExceeded(_))));
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
model.progression.money = MAX_MONEY + 1;
|
||||
assert!(encode(&model).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_item_structure_is_refused() {
|
||||
// an item inside a container that does not exist
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
let orphan = model.items.iter().position(|i| i.container.is_some()).unwrap();
|
||||
model.items[orphan].container = Some(crate::portable::ids::PortableItemId::new());
|
||||
assert!(encode(&model).is_err());
|
||||
// two items in the same place
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
let mut copy = model.items[0].clone();
|
||||
copy.id = crate::portable::ids::PortableItemId::new();
|
||||
model.items.push(copy);
|
||||
assert!(encode(&model).is_err());
|
||||
// duplicate item ids
|
||||
let mut model = fixtures::geared_level_eighty();
|
||||
let dup = model.items[0].clone();
|
||||
model.items.push(dup);
|
||||
assert!(encode(&model).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_geared_snapshot_is_small() {
|
||||
let encoded = encode(&fixtures::geared_level_eighty()).unwrap();
|
||||
assert!(encoded.payload.len() < 64 * 1024, "{} bytes", encoded.payload.len());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
//! The local canonical store (SQLite, WAL). This is the **source of truth** for portable characters: realms only
|
||||
//! hold working copies. Every write is one transaction; every mutation of a character is checked against the revision
|
||||
//! the caller saw (`StaleRevision`), so a stale writer can never overwrite newer state.
|
||||
//!
|
||||
//! Nothing here touches a realm database.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
|
||||
|
||||
use super::collection::{valid_kind, IdSet};
|
||||
use super::error::{PortableError, Result};
|
||||
use super::ids::{CharacterId, PortableItemId, ProfileId};
|
||||
use super::model::{PortableCharacter, Ruleset};
|
||||
use super::snapshot::{self, EncodedSnapshot};
|
||||
use super::versions::{PORTABLE_COLLECTION_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION};
|
||||
|
||||
pub const DATABASE_FILE: &str = "portable.db";
|
||||
/// `PRAGMA application_id`: "COAP". Refuses to adopt an unrelated SQLite file.
|
||||
const APPLICATION_ID: i64 = 0x434F_4150;
|
||||
const MIGRATIONS: &[&str] = &[include_str!("migrations/001_init.sql")];
|
||||
|
||||
pub const DEFAULT_HISTORY_KEEP: u32 = 20;
|
||||
pub const MAX_HISTORY_KEEP: u32 = 1_000;
|
||||
|
||||
pub struct Store {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CharacterRecord {
|
||||
pub character_id: CharacterId,
|
||||
pub profile_id: ProfileId,
|
||||
pub ruleset: Ruleset,
|
||||
pub name: String,
|
||||
pub race: String,
|
||||
pub class: String,
|
||||
pub gender: u8,
|
||||
pub level: u8,
|
||||
pub revision: u64,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RevisionInfo {
|
||||
pub revision: u64,
|
||||
pub created_at: String,
|
||||
pub source_server_id: String,
|
||||
pub content_hash: [u8; 32],
|
||||
pub uncompressed_size: u64,
|
||||
pub stored_size: u64,
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MappingState {
|
||||
/// Bound, nothing imported yet.
|
||||
Pending,
|
||||
/// The character is (or may be) in play on that realm.
|
||||
Active,
|
||||
/// The realm's copy equals `last_revision`.
|
||||
Synced,
|
||||
/// The realm's copy is older than canonical.
|
||||
Stale,
|
||||
}
|
||||
|
||||
impl MappingState {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
MappingState::Pending => "pending",
|
||||
MappingState::Active => "active",
|
||||
MappingState::Synced => "synced",
|
||||
MappingState::Stale => "stale",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Result<Self> {
|
||||
Ok(match s {
|
||||
"pending" => MappingState::Pending,
|
||||
"active" => MappingState::Active,
|
||||
"synced" => MappingState::Synced,
|
||||
"stale" => MappingState::Stale,
|
||||
other => return Err(PortableError::Invalid(format!("unknown mapping state {other:?}"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MappingRecord {
|
||||
pub character_id: CharacterId,
|
||||
pub server_id: String,
|
||||
pub local_guid: u32,
|
||||
pub last_revision: u64,
|
||||
pub state: MappingState,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CollectionInfo {
|
||||
pub kind: String,
|
||||
pub revision: u64,
|
||||
pub hash: [u8; 32],
|
||||
pub count: usize,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CollectionMerge {
|
||||
/// `false` when the incoming set added nothing: no revision was created.
|
||||
pub changed: bool,
|
||||
pub added: usize,
|
||||
/// `None` only when nothing is stored and nothing was added.
|
||||
pub info: Option<CollectionInfo>,
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
|
||||
}
|
||||
|
||||
pub fn valid_server_id(id: &str) -> bool {
|
||||
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b))
|
||||
}
|
||||
|
||||
fn check_server_id(id: &str) -> Result<()> {
|
||||
if valid_server_id(id) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(PortableError::Invalid(format!("invalid server id {id:?}")))
|
||||
}
|
||||
}
|
||||
|
||||
fn hash_from_blob(blob: Vec<u8>) -> Result<[u8; 32]> {
|
||||
blob.try_into().map_err(|_| PortableError::CorruptSnapshot("a stored hash is not 32 bytes".into()))
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Open (creating it if necessary) `<dir>/portable.db`.
|
||||
pub fn open(dir: &Path) -> Result<Store> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
Store::open_file(&dir.join(DATABASE_FILE))
|
||||
}
|
||||
|
||||
pub fn open_file(file: &Path) -> Result<Store> {
|
||||
let conn = Connection::open(file)?;
|
||||
// WAL keeps readers out of writers' way and survives a crash mid-write; FULL sync keeps the canonical state
|
||||
// durable even on power loss.
|
||||
conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get::<_, String>(0))?;
|
||||
conn.pragma_update(None, "synchronous", "FULL")?;
|
||||
Store::init(conn)
|
||||
}
|
||||
|
||||
pub fn open_in_memory() -> Result<Store> {
|
||||
Store::init(Connection::open_in_memory()?)
|
||||
}
|
||||
|
||||
fn init(conn: Connection) -> Result<Store> {
|
||||
conn.pragma_update(None, "foreign_keys", true)?;
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
let mut store = Store { conn };
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
fn migrate(&mut self) -> Result<()> {
|
||||
let version: i64 = self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
|
||||
let application: i64 = self.conn.pragma_query_value(None, "application_id", |r| r.get(0))?;
|
||||
let supported = MIGRATIONS.len() as i64;
|
||||
if application != APPLICATION_ID {
|
||||
let objects: i64 = self.conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| r.get(0))?;
|
||||
if application != 0 || version != 0 || objects != 0 {
|
||||
return Err(PortableError::NotPortableDatabase);
|
||||
}
|
||||
}
|
||||
if version > supported {
|
||||
return Err(PortableError::NewerDatabase { found: version, supported });
|
||||
}
|
||||
for next in version..supported {
|
||||
let tx = self.conn.transaction()?;
|
||||
tx.execute_batch(MIGRATIONS[next as usize])?;
|
||||
tx.pragma_update(None, "application_id", APPLICATION_ID)?;
|
||||
tx.pragma_update(None, "user_version", next + 1)?;
|
||||
tx.commit()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn schema_version(&self) -> Result<i64> {
|
||||
Ok(self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?)
|
||||
}
|
||||
|
||||
fn write_tx(&mut self) -> Result<Transaction<'_>> {
|
||||
Ok(self.conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
|
||||
}
|
||||
|
||||
// ---- settings -------------------------------------------------------------------------------------------
|
||||
|
||||
/// How many revisions of one character are kept (the newest ones).
|
||||
pub fn history_keep(&self) -> Result<u32> {
|
||||
let value: Option<String> = self.conn.query_row("SELECT value FROM setting WHERE key = 'history_keep'", [], |r| r.get(0)).optional()?;
|
||||
Ok(value.and_then(|v| v.parse().ok()).filter(|n| (1..=MAX_HISTORY_KEEP).contains(n)).unwrap_or(DEFAULT_HISTORY_KEEP))
|
||||
}
|
||||
|
||||
pub fn set_history_keep(&mut self, keep: u32) -> Result<()> {
|
||||
if !(1..=MAX_HISTORY_KEEP).contains(&keep) {
|
||||
return Err(PortableError::Invalid(format!("history_keep must be between 1 and {MAX_HISTORY_KEEP}")));
|
||||
}
|
||||
self.conn.execute("INSERT INTO setting(key, value) VALUES ('history_keep', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [keep.to_string()])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- profiles -------------------------------------------------------------------------------------------
|
||||
|
||||
pub fn create_profile(&mut self) -> Result<ProfileId> {
|
||||
let id = ProfileId::new();
|
||||
self.conn.execute(
|
||||
"INSERT INTO profile(profile_id, created_at, format_version) VALUES (?1, ?2, ?3)",
|
||||
params![id.to_string(), now(), super::versions::PORTABLE_CHARACTER_FORMAT_VERSION],
|
||||
)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The profile of this Manager: the oldest one, created on first use.
|
||||
pub fn default_profile(&mut self) -> Result<ProfileId> {
|
||||
let tx = self.write_tx()?;
|
||||
let existing: Option<String> = tx.query_row("SELECT profile_id FROM profile ORDER BY profile_id LIMIT 1", [], |r| r.get(0)).optional()?;
|
||||
let id = match existing {
|
||||
Some(text) => text.parse()?,
|
||||
None => {
|
||||
let id = ProfileId::new();
|
||||
tx.execute(
|
||||
"INSERT INTO profile(profile_id, created_at, format_version) VALUES (?1, ?2, ?3)",
|
||||
params![id.to_string(), now(), super::versions::PORTABLE_CHARACTER_FORMAT_VERSION],
|
||||
)?;
|
||||
id
|
||||
}
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
// ---- characters -----------------------------------------------------------------------------------------
|
||||
|
||||
/// Create a character with a **new** UUIDv7 (the one in `model.character_id` is replaced). Revision 1.
|
||||
pub fn create_character(&mut self, profile: ProfileId, mut model: PortableCharacter, source_server_id: &str) -> Result<CharacterId> {
|
||||
model.character_id = CharacterId::new();
|
||||
self.create_character_with_id(profile, model, source_server_id)
|
||||
}
|
||||
|
||||
/// Create a character that already has its identity (restoring or receiving an existing portable character).
|
||||
/// An id that exists is refused and nothing is written.
|
||||
pub fn create_character_with_id(&mut self, profile: ProfileId, model: PortableCharacter, source_server_id: &str) -> Result<CharacterId> {
|
||||
check_server_id(source_server_id)?;
|
||||
let encoded = snapshot::encode(&model)?;
|
||||
let id = model.character_id;
|
||||
let tx = self.write_tx()?;
|
||||
let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM character WHERE character_id = ?1)", [id.to_string()], |r| r.get(0))?;
|
||||
if exists {
|
||||
return Err(PortableError::DuplicateCharacter(id));
|
||||
}
|
||||
let profile_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM profile WHERE profile_id = ?1)", [profile.to_string()], |r| r.get(0))?;
|
||||
if !profile_exists {
|
||||
return Err(PortableError::Invalid(format!("profile {profile} does not exist")));
|
||||
}
|
||||
let at = now();
|
||||
tx.execute(
|
||||
"INSERT INTO character(character_id, profile_id, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?9, 0)",
|
||||
params![
|
||||
id.to_string(),
|
||||
profile.to_string(),
|
||||
model.ruleset.as_str(),
|
||||
model.identity.name,
|
||||
model.identity.race.to_string(),
|
||||
model.identity.class.to_string(),
|
||||
model.identity.gender,
|
||||
model.progression.level,
|
||||
at
|
||||
],
|
||||
)?;
|
||||
insert_snapshot(&tx, id, 1, &encoded, source_server_id, Some("created"), &at)?;
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn character(&self, id: CharacterId) -> Result<CharacterRecord> {
|
||||
read_character(&self.conn, id)
|
||||
}
|
||||
|
||||
pub fn list_characters(&self, profile: ProfileId) -> Result<Vec<CharacterRecord>> {
|
||||
let mut stmt = self.conn.prepare("SELECT character_id FROM character WHERE profile_id = ?1 ORDER BY character_id")?;
|
||||
let ids: Vec<String> = stmt.query_map([profile.to_string()], |r| r.get(0))?.collect::<std::result::Result<_, _>>()?;
|
||||
ids.iter().map(|text| read_character(&self.conn, text.parse()?)).collect()
|
||||
}
|
||||
|
||||
pub fn set_archived(&mut self, id: CharacterId, archived: bool) -> Result<()> {
|
||||
let changed = self.conn.execute("UPDATE character SET archived = ?2 WHERE character_id = ?1", params![id.to_string(), archived])?;
|
||||
if changed == 0 {
|
||||
return Err(PortableError::UnknownCharacter(id));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store the next canonical revision. `expected_revision` is the revision the caller based its change on; if the
|
||||
/// canonical revision has moved on, nothing is written and `StaleRevision` is returned. Returns the new revision.
|
||||
pub fn commit_snapshot(&mut self, id: CharacterId, expected_revision: u64, model: PortableCharacter, source_server_id: &str, note: Option<&str>) -> Result<u64> {
|
||||
check_server_id(source_server_id)?;
|
||||
if model.character_id != id {
|
||||
return Err(PortableError::WrongCharacter { expected: id, found: model.character_id });
|
||||
}
|
||||
let encoded = snapshot::encode(&model)?;
|
||||
let keep = self.history_keep()?;
|
||||
let tx = self.write_tx()?;
|
||||
let current = read_character(&tx, id)?;
|
||||
if current.ruleset != model.ruleset {
|
||||
return Err(PortableError::RulesetChange { from: current.ruleset.to_string(), to: model.ruleset.to_string() });
|
||||
}
|
||||
if current.revision != expected_revision {
|
||||
return Err(PortableError::StaleRevision { expected: expected_revision, current: current.revision });
|
||||
}
|
||||
let revision = current.revision + 1;
|
||||
let at = now();
|
||||
insert_snapshot(&tx, id, revision, &encoded, source_server_id, note, &at)?;
|
||||
update_character(&tx, id, &model, revision, &at)?;
|
||||
prune_tx(&tx, id, revision, keep)?;
|
||||
tx.commit()?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
/// Make an older revision current **as a new revision** (revision numbers never go down, so freshness stays a
|
||||
/// plain integer comparison). Returns the new revision.
|
||||
pub fn rollback_to(&mut self, id: CharacterId, expected_revision: u64, target_revision: u64, source_server_id: &str) -> Result<u64> {
|
||||
check_server_id(source_server_id)?;
|
||||
let keep = self.history_keep()?;
|
||||
let tx = self.write_tx()?;
|
||||
let current = read_character(&tx, id)?;
|
||||
if current.revision != expected_revision {
|
||||
return Err(PortableError::StaleRevision { expected: expected_revision, current: current.revision });
|
||||
}
|
||||
let (model, encoded) = read_snapshot_row(&tx, id, target_revision)?;
|
||||
let revision = current.revision + 1;
|
||||
let at = now();
|
||||
insert_snapshot(&tx, id, revision, &encoded, source_server_id, Some(&format!("rollback to revision {target_revision}")), &at)?;
|
||||
update_character(&tx, id, &model, revision, &at)?;
|
||||
prune_tx(&tx, id, revision, keep)?;
|
||||
tx.commit()?;
|
||||
Ok(revision)
|
||||
}
|
||||
|
||||
pub fn list_revisions(&self, id: CharacterId) -> Result<Vec<RevisionInfo>> {
|
||||
read_character(&self.conn, id)?;
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT revision, created_at, source_server_id, content_hash, uncompressed_size, length(payload), note
|
||||
FROM snapshot WHERE character_id = ?1 ORDER BY revision",
|
||||
)?;
|
||||
let rows = stmt.query_map([id.to_string()], |r| {
|
||||
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, Option<String>>(6)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (revision, created_at, source_server_id, hash, uncompressed, stored, note) = row?;
|
||||
out.push(RevisionInfo {
|
||||
revision: revision as u64,
|
||||
created_at,
|
||||
source_server_id,
|
||||
content_hash: hash_from_blob(hash)?,
|
||||
uncompressed_size: uncompressed as u64,
|
||||
stored_size: stored as u64,
|
||||
note,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One stored revision, verified (hash, limits, structure).
|
||||
pub fn load_snapshot(&self, id: CharacterId, revision: u64) -> Result<PortableCharacter> {
|
||||
Ok(read_snapshot_row(&self.conn, id, revision)?.0)
|
||||
}
|
||||
|
||||
pub fn load_current(&self, id: CharacterId) -> Result<PortableCharacter> {
|
||||
let record = read_character(&self.conn, id)?;
|
||||
self.load_snapshot(id, record.revision)
|
||||
}
|
||||
|
||||
/// Remove revisions beyond the history limit. The current revision is never removed. Returns how many went.
|
||||
pub fn prune(&mut self, id: CharacterId) -> Result<usize> {
|
||||
let keep = self.history_keep()?;
|
||||
let tx = self.write_tx()?;
|
||||
let current = read_character(&tx, id)?;
|
||||
let removed = prune_tx(&tx, id, current.revision, keep)?;
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
// ---- realm mappings -------------------------------------------------------------------------------------
|
||||
|
||||
/// Record (or update) the local guid of a portable character on a realm. A local guid can belong to one portable
|
||||
/// character only. Changing the local guid of an existing binding drops that binding's item mappings (they named
|
||||
/// items of the previous local character).
|
||||
pub fn bind_server(&mut self, id: CharacterId, server_id: &str, local_guid: u32, last_revision: u64, state: MappingState) -> Result<()> {
|
||||
check_server_id(server_id)?;
|
||||
let tx = self.write_tx()?;
|
||||
let record = read_character(&tx, id)?;
|
||||
if last_revision > record.revision {
|
||||
return Err(PortableError::Invalid(format!("revision {last_revision} is newer than the canonical revision {}", record.revision)));
|
||||
}
|
||||
let holder: Option<String> = tx
|
||||
.query_row("SELECT character_id FROM character_server_mapping WHERE server_id = ?1 AND local_guid = ?2", params![server_id, local_guid], |r| r.get(0))
|
||||
.optional()?;
|
||||
if holder.is_some_and(|h| h != id.to_string()) {
|
||||
return Err(PortableError::LocalGuidTaken { server_id: server_id.to_string(), local_guid });
|
||||
}
|
||||
let previous: Option<i64> = tx
|
||||
.query_row("SELECT local_guid FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id], |r| r.get(0))
|
||||
.optional()?;
|
||||
if previous.is_some_and(|p| p != local_guid as i64) {
|
||||
tx.execute("DELETE FROM item_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id])?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(character_id, server_id) DO UPDATE SET local_guid = excluded.local_guid, last_revision = excluded.last_revision,
|
||||
state = excluded.state, updated_at = excluded.updated_at",
|
||||
params![id.to_string(), server_id, local_guid, last_revision as i64, state.as_str(), now()],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The portable character a realm's local character belongs to. Checkpoints arrive keyed by the local guid.
|
||||
pub fn find_by_local(&self, server_id: &str, local_guid: u32) -> Result<Option<CharacterId>> {
|
||||
let found: Option<String> = self
|
||||
.conn
|
||||
.query_row("SELECT character_id FROM character_server_mapping WHERE server_id = ?1 AND local_guid = ?2", params![server_id, local_guid], |r| r.get(0))
|
||||
.optional()?;
|
||||
found.map(|text| text.parse()).transpose()
|
||||
}
|
||||
|
||||
pub fn server_mappings(&self, id: CharacterId) -> Result<Vec<MappingRecord>> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT server_id, local_guid, last_revision, state, updated_at FROM character_server_mapping WHERE character_id = ?1 ORDER BY server_id",
|
||||
)?;
|
||||
let rows = stmt.query_map([id.to_string()], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?)))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (server_id, guid, revision, state, updated_at) = row?;
|
||||
out.push(MappingRecord { character_id: id, server_id, local_guid: guid as u32, last_revision: revision as u64, state: MappingState::parse(&state)?, updated_at });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Replace the item mappings of one (character, realm) pair. Every portable item id and every local item guid may
|
||||
/// appear once. The pair must already be bound with [`Store::bind_server`].
|
||||
pub fn set_item_mappings(&mut self, id: CharacterId, server_id: &str, mappings: &[(PortableItemId, u32)]) -> Result<()> {
|
||||
check_server_id(server_id)?;
|
||||
let mut portable = std::collections::HashSet::new();
|
||||
let mut local = std::collections::HashSet::new();
|
||||
for (item, guid) in mappings {
|
||||
if !portable.insert(*item) {
|
||||
return Err(PortableError::Invalid(format!("portable item {item} is mapped twice")));
|
||||
}
|
||||
if !local.insert(*guid) {
|
||||
return Err(PortableError::ItemGuidConflict { server_id: server_id.to_string(), local_item_guid: *guid });
|
||||
}
|
||||
}
|
||||
let tx = self.write_tx()?;
|
||||
let bound: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2)",
|
||||
params![id.to_string(), server_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !bound {
|
||||
return Err(PortableError::Invalid(format!("character {id} is not bound to server {server_id}")));
|
||||
}
|
||||
tx.execute("DELETE FROM item_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id])?;
|
||||
{
|
||||
let mut insert = tx.prepare("INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid) VALUES (?1, ?2, ?3, ?4)")?;
|
||||
for (item, guid) in mappings {
|
||||
insert.execute(params![id.to_string(), server_id, item.to_string(), guid])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn item_mappings(&self, id: CharacterId, server_id: &str) -> Result<Vec<(PortableItemId, u32)>> {
|
||||
let mut stmt = self.conn.prepare("SELECT portable_item_id, local_item_guid FROM item_mapping WHERE character_id = ?1 AND server_id = ?2 ORDER BY local_item_guid")?;
|
||||
let rows = stmt.query_map(params![id.to_string(), server_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (item, guid) = row?;
|
||||
out.push((item.parse()?, guid as u32));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- collections ----------------------------------------------------------------------------------------
|
||||
|
||||
/// `stored = stored U incoming`. Nothing is ever removed. When the union equals what is stored, no revision is
|
||||
/// created and the hash stays the same.
|
||||
pub fn merge_collection(&mut self, profile: ProfileId, kind: &str, incoming: &IdSet) -> Result<CollectionMerge> {
|
||||
if !valid_kind(kind) {
|
||||
return Err(PortableError::Invalid(format!("invalid collection kind {kind:?}")));
|
||||
}
|
||||
let tx = self.write_tx()?;
|
||||
let profile_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM profile WHERE profile_id = ?1)", [profile.to_string()], |r| r.get(0))?;
|
||||
if !profile_exists {
|
||||
return Err(PortableError::Invalid(format!("profile {profile} does not exist")));
|
||||
}
|
||||
let stored = read_collection(&tx, profile, kind)?;
|
||||
let (current_set, revision) = match &stored {
|
||||
Some((info, set)) => (set.clone(), info.revision),
|
||||
None => (IdSet::new(), 0),
|
||||
};
|
||||
let added = current_set.count_new(incoming);
|
||||
if added == 0 {
|
||||
tx.commit()?;
|
||||
return Ok(CollectionMerge { changed: false, added: 0, info: stored.map(|(info, _)| info) });
|
||||
}
|
||||
let merged = current_set.union(incoming);
|
||||
let hash = merged.hash(kind);
|
||||
let payload = merged.encode();
|
||||
let at = now();
|
||||
tx.execute(
|
||||
"INSERT INTO collection(profile_id, kind, collection_revision, collection_hash, format_version, item_count, updated_at, payload)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(profile_id, kind) DO UPDATE SET collection_revision = excluded.collection_revision, collection_hash = excluded.collection_hash,
|
||||
format_version = excluded.format_version, item_count = excluded.item_count, updated_at = excluded.updated_at, payload = excluded.payload",
|
||||
params![profile.to_string(), kind, (revision + 1) as i64, hash.as_slice(), PORTABLE_COLLECTION_FORMAT_VERSION, merged.len() as i64, at, payload],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(CollectionMerge {
|
||||
changed: true,
|
||||
added,
|
||||
info: Some(CollectionInfo { kind: kind.to_string(), revision: revision + 1, hash, count: merged.len(), updated_at: at }),
|
||||
})
|
||||
}
|
||||
|
||||
/// Revision, hash and size without loading the set: the "did it change?" check.
|
||||
pub fn collection_info(&self, profile: ProfileId, kind: &str) -> Result<Option<CollectionInfo>> {
|
||||
let row = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT collection_revision, collection_hash, item_count, updated_at FROM collection WHERE profile_id = ?1 AND kind = ?2",
|
||||
params![profile.to_string(), kind],
|
||||
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
row.map(|(revision, hash, count, updated_at)| Ok(CollectionInfo { kind: kind.to_string(), revision: revision as u64, hash: hash_from_blob(hash)?, count: count as usize, updated_at }))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
pub fn collection(&self, profile: ProfileId, kind: &str) -> Result<Option<(CollectionInfo, IdSet)>> {
|
||||
read_collection(&self.conn, profile, kind)
|
||||
}
|
||||
|
||||
pub fn path_of(dir: &Path) -> PathBuf {
|
||||
dir.join(DATABASE_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_collection(conn: &Connection, profile: ProfileId, kind: &str) -> Result<Option<(CollectionInfo, IdSet)>> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT collection_revision, collection_hash, item_count, updated_at, payload FROM collection WHERE profile_id = ?1 AND kind = ?2",
|
||||
params![profile.to_string(), kind],
|
||||
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?, r.get::<_, Vec<u8>>(4)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((revision, hash, count, updated_at, payload)) = row else { return Ok(None) };
|
||||
let set = IdSet::decode(&payload)?;
|
||||
let hash = hash_from_blob(hash)?;
|
||||
if set.hash(kind) != hash || set.len() != count as usize {
|
||||
return Err(PortableError::CorruptSnapshot(format!("collection {kind} does not match its recorded hash")));
|
||||
}
|
||||
Ok(Some((CollectionInfo { kind: kind.to_string(), revision: revision as u64, hash, count: set.len(), updated_at }, set)))
|
||||
}
|
||||
|
||||
fn read_character(conn: &Connection, id: CharacterId) -> Result<CharacterRecord> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT profile_id, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived FROM character WHERE character_id = ?1",
|
||||
[id.to_string()],
|
||||
|r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, u8>(5)?,
|
||||
r.get::<_, u8>(6)?,
|
||||
r.get::<_, i64>(7)?,
|
||||
r.get::<_, String>(8)?,
|
||||
r.get::<_, String>(9)?,
|
||||
r.get::<_, bool>(10)?,
|
||||
))
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
let Some((profile, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived)) = row else {
|
||||
return Err(PortableError::UnknownCharacter(id));
|
||||
};
|
||||
Ok(CharacterRecord {
|
||||
character_id: id,
|
||||
profile_id: profile.parse()?,
|
||||
ruleset: Ruleset::parse(&ruleset)?,
|
||||
name,
|
||||
race,
|
||||
class,
|
||||
gender,
|
||||
level,
|
||||
revision: revision as u64,
|
||||
created_at,
|
||||
updated_at,
|
||||
archived,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert_snapshot(tx: &Transaction<'_>, id: CharacterId, revision: u64, encoded: &EncodedSnapshot, source_server_id: &str, note: Option<&str>, at: &str) -> Result<()> {
|
||||
tx.execute(
|
||||
"INSERT INTO snapshot(character_id, revision, snapshot_format_version, created_at, source_server_id, content_hash, uncompressed_size, payload, note)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
params![id.to_string(), revision as i64, SNAPSHOT_FORMAT_VERSION, at, source_server_id, encoded.content_hash.as_slice(), encoded.uncompressed_size as i64, encoded.payload, note],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_character(tx: &Transaction<'_>, id: CharacterId, model: &PortableCharacter, revision: u64, at: &str) -> Result<()> {
|
||||
tx.execute(
|
||||
"UPDATE character SET name = ?2, race = ?3, class = ?4, gender = ?5, level = ?6, revision = ?7, updated_at = ?8 WHERE character_id = ?1",
|
||||
params![id.to_string(), model.identity.name, model.identity.race.to_string(), model.identity.class.to_string(), model.identity.gender, model.progression.level, revision as i64, at],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete everything older than the newest `keep` revisions; `current` is always among the newest.
|
||||
fn prune_tx(tx: &Transaction<'_>, id: CharacterId, current: u64, keep: u32) -> Result<usize> {
|
||||
let cutoff = current as i64 - keep as i64;
|
||||
if cutoff < 1 {
|
||||
return Ok(0);
|
||||
}
|
||||
Ok(tx.execute("DELETE FROM snapshot WHERE character_id = ?1 AND revision <= ?2 AND revision < ?3", params![id.to_string(), cutoff, current as i64])?)
|
||||
}
|
||||
|
||||
/// Read one revision and verify it against its recorded hash. Also returns the re-encoded form so a rollback can store
|
||||
/// exactly the same payload.
|
||||
fn read_snapshot_row(conn: &Connection, id: CharacterId, revision: u64) -> Result<(PortableCharacter, EncodedSnapshot)> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT snapshot_format_version, content_hash, uncompressed_size, payload FROM snapshot WHERE character_id = ?1 AND revision = ?2",
|
||||
params![id.to_string(), revision as i64],
|
||||
|r| Ok((r.get::<_, u32>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((format, hash, size, payload)) = row else {
|
||||
read_character(conn, id)?;
|
||||
return Err(PortableError::UnknownRevision { character: id, revision });
|
||||
};
|
||||
if format > SNAPSHOT_FORMAT_VERSION {
|
||||
return Err(PortableError::UnsupportedFormat { found: format, supported: SNAPSHOT_FORMAT_VERSION });
|
||||
}
|
||||
let hash = hash_from_blob(hash)?;
|
||||
let model = snapshot::decode(&payload, Some(&hash))?;
|
||||
if model.character_id != id {
|
||||
return Err(PortableError::CorruptSnapshot("a stored snapshot names another character".into()));
|
||||
}
|
||||
Ok((model, EncodedSnapshot { content_hash: hash, uncompressed_size: size as u64, payload }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,455 @@
|
||||
use super::*;
|
||||
use crate::portable::fixtures::{self, character_id};
|
||||
|
||||
fn fresh() -> (Store, ProfileId) {
|
||||
let mut store = Store::open_in_memory().unwrap();
|
||||
let profile = store.default_profile().unwrap();
|
||||
(store, profile)
|
||||
}
|
||||
|
||||
fn created(store: &mut Store, profile: ProfileId) -> CharacterId {
|
||||
store.create_character(profile, fixtures::geared_level_eighty(), "realm-a").unwrap()
|
||||
}
|
||||
|
||||
fn with_money(mut model: PortableCharacter, money: u32) -> PortableCharacter {
|
||||
model.progression.money = money;
|
||||
model
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_character_identity() {
|
||||
let (mut store, profile) = fresh();
|
||||
let model = fixtures::geared_level_eighty();
|
||||
let id = store.create_character(profile, model.clone(), "realm-a").unwrap();
|
||||
|
||||
// a fresh UUIDv7, not the placeholder carried by the model
|
||||
assert_ne!(id, model.character_id);
|
||||
assert_eq!(id.as_uuid().get_version_num(), 7);
|
||||
|
||||
let record = store.character(id).unwrap();
|
||||
assert_eq!(record.revision, 1);
|
||||
assert_eq!((record.name.as_str(), record.level, record.ruleset), ("Geared", 80, Ruleset::Coa));
|
||||
assert_eq!(record.race, "coa:race:1");
|
||||
assert_eq!(record.class, "coa:class:21");
|
||||
assert_eq!(record.profile_id, profile);
|
||||
assert!(!record.archived);
|
||||
|
||||
let loaded = store.load_current(id).unwrap();
|
||||
let mut expected = model.normalized();
|
||||
expected.character_id = id;
|
||||
assert_eq!(loaded, expected, "the stored revision 1 is the model that was passed in");
|
||||
|
||||
let revisions = store.list_revisions(id).unwrap();
|
||||
assert_eq!(revisions.len(), 1);
|
||||
assert_eq!((revisions[0].revision, revisions[0].source_server_id.as_str(), revisions[0].note.as_deref()), (1, "realm-a", Some("created")));
|
||||
assert_eq!(store.list_characters(profile).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_new_characters_never_share_an_id() {
|
||||
let (mut store, profile) = fresh();
|
||||
let a = created(&mut store, profile);
|
||||
let b = created(&mut store, profile);
|
||||
assert_ne!(a, b);
|
||||
assert!(a < b, "ids are time-ordered");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_increments_monotonically() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let mut model = store.load_current(id).unwrap();
|
||||
for expected in 2..=6u64 {
|
||||
model.progression.xp += 100;
|
||||
let revision = store.commit_snapshot(id, expected - 1, model.clone(), "realm-b", Some("checkpoint")).unwrap();
|
||||
assert_eq!(revision, expected);
|
||||
assert_eq!(store.character(id).unwrap().revision, expected);
|
||||
}
|
||||
let revisions: Vec<u64> = store.list_revisions(id).unwrap().iter().map(|r| r.revision).collect();
|
||||
assert_eq!(revisions, vec![1, 2, 3, 4, 5, 6]);
|
||||
assert_eq!(store.load_current(id).unwrap().progression.xp, model.progression.xp);
|
||||
assert_eq!(store.character(id).unwrap().level, 80);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_updates_the_summary_row() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let mut model = store.load_current(id).unwrap();
|
||||
model.identity.name = "Renamed".into();
|
||||
model.progression.level = 81;
|
||||
store.commit_snapshot(id, 1, model, "realm-a", None).unwrap();
|
||||
let record = store.character(id).unwrap();
|
||||
assert_eq!((record.name.as_str(), record.level, record.revision), ("Renamed", 81, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_revision_is_rejected_and_nothing_is_written() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let base = store.load_current(id).unwrap();
|
||||
|
||||
// a fresher state is committed first
|
||||
store.commit_snapshot(id, 1, with_money(base.clone(), 500), "realm-a", None).unwrap();
|
||||
let before = store.list_revisions(id).unwrap();
|
||||
|
||||
// a writer that still believes revision 1 is current must lose
|
||||
let result = store.commit_snapshot(id, 1, with_money(base.clone(), 999_999), "realm-b", None);
|
||||
assert!(matches!(result, Err(PortableError::StaleRevision { expected: 1, current: 2 })), "{result:?}");
|
||||
// so must one that claims a revision from the future
|
||||
let result = store.commit_snapshot(id, 7, with_money(base, 1), "realm-b", None);
|
||||
assert!(matches!(result, Err(PortableError::StaleRevision { expected: 7, current: 2 })), "{result:?}");
|
||||
|
||||
assert_eq!(store.list_revisions(id).unwrap(), before, "a rejected commit leaves no trace");
|
||||
assert_eq!(store.load_current(id).unwrap().progression.money, 500);
|
||||
assert_eq!(store.character(id).unwrap().revision, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_stores_on_one_file_cannot_both_win() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut a = Store::open(dir.path()).unwrap();
|
||||
let mut b = Store::open(dir.path()).unwrap();
|
||||
let profile = a.default_profile().unwrap();
|
||||
let id = created(&mut a, profile);
|
||||
let base = b.load_current(id).unwrap();
|
||||
|
||||
a.commit_snapshot(id, 1, with_money(base.clone(), 10), "realm-a", None).unwrap();
|
||||
let late = b.commit_snapshot(id, 1, with_money(base, 20), "realm-b", None);
|
||||
assert!(matches!(late, Err(PortableError::StaleRevision { .. })), "{late:?}");
|
||||
assert_eq!(b.load_current(id).unwrap().progression.money, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_uuid_is_rejected_and_the_original_is_untouched() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let original = store.load_current(id).unwrap();
|
||||
|
||||
let mut impostor = fixtures::naked_level_one();
|
||||
impostor.character_id = id;
|
||||
let result = store.create_character_with_id(profile, impostor, "realm-x");
|
||||
assert!(matches!(result, Err(PortableError::DuplicateCharacter(dup)) if dup == id), "{result:?}");
|
||||
|
||||
assert_eq!(store.load_current(id).unwrap(), original);
|
||||
assert_eq!(store.list_revisions(id).unwrap().len(), 1);
|
||||
assert_eq!(store.list_characters(profile).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restored_character_keeps_its_own_identity() {
|
||||
let (mut store, profile) = fresh();
|
||||
let model = fixtures::naked_level_one();
|
||||
let id = store.create_character_with_id(profile, model.clone(), "realm-a").unwrap();
|
||||
assert_eq!(id, model.character_id);
|
||||
assert_eq!(id, character_id(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_character_ruleset_and_unknown_character_are_refused() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let other = store.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap();
|
||||
|
||||
// a snapshot of another character cannot be committed under this id
|
||||
let foreign = store.load_current(other).unwrap();
|
||||
assert!(matches!(store.commit_snapshot(id, 1, foreign, "realm-a", None), Err(PortableError::WrongCharacter { .. })));
|
||||
|
||||
// CoA and Wildcard never mix
|
||||
let mut wildcard = store.load_current(id).unwrap();
|
||||
wildcard.ruleset = Ruleset::Wildcard;
|
||||
assert!(matches!(store.commit_snapshot(id, 1, wildcard, "realm-a", None), Err(PortableError::RulesetChange { .. })));
|
||||
|
||||
let missing = CharacterId::new();
|
||||
assert!(matches!(store.character(missing), Err(PortableError::UnknownCharacter(_))));
|
||||
assert!(matches!(store.list_revisions(missing), Err(PortableError::UnknownCharacter(_))));
|
||||
assert_eq!(store.character(id).unwrap().revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_model_is_refused_without_side_effects() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let mut broken = store.load_current(id).unwrap();
|
||||
broken.progression.money = u32::MAX;
|
||||
assert!(store.commit_snapshot(id, 1, broken, "realm-a", None).is_err());
|
||||
assert!(store.commit_snapshot(id, 1, store.load_current(id).unwrap(), "bad server id!", None).is_err());
|
||||
assert_eq!(store.character(id).unwrap().revision, 1);
|
||||
assert_eq!(store.list_revisions(id).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_creates_a_new_revision_with_the_old_state() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let rev1 = store.load_current(id).unwrap();
|
||||
store.commit_snapshot(id, 1, with_money(rev1.clone(), 111), "realm-a", None).unwrap();
|
||||
store.commit_snapshot(id, 2, with_money(rev1.clone(), 222), "realm-a", None).unwrap();
|
||||
|
||||
let new = store.rollback_to(id, 3, 1, "manager").unwrap();
|
||||
assert_eq!(new, 4, "revision numbers never go down");
|
||||
assert_eq!(store.character(id).unwrap().revision, 4);
|
||||
assert_eq!(store.load_current(id).unwrap(), rev1, "the state is revision 1's");
|
||||
|
||||
let revisions = store.list_revisions(id).unwrap();
|
||||
assert_eq!(revisions.len(), 4, "history is kept, nothing is rewritten");
|
||||
assert_eq!(revisions[3].note.as_deref(), Some("rollback to revision 1"));
|
||||
assert_eq!(revisions[3].content_hash, revisions[0].content_hash);
|
||||
assert_eq!(store.load_snapshot(id, 3).unwrap().progression.money, 222, "the state before the rollback is still there");
|
||||
|
||||
// a rollback is itself a revision: a stale caller cannot roll back on top of newer state
|
||||
assert!(matches!(store.rollback_to(id, 3, 1, "manager"), Err(PortableError::StaleRevision { .. })));
|
||||
assert!(matches!(store.rollback_to(id, 4, 99, "manager"), Err(PortableError::UnknownRevision { revision: 99, .. })));
|
||||
assert_eq!(store.character(id).unwrap().revision, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_is_capped_and_the_current_revision_survives() {
|
||||
let (mut store, profile) = fresh();
|
||||
assert_eq!(store.history_keep().unwrap(), DEFAULT_HISTORY_KEEP);
|
||||
store.set_history_keep(3).unwrap();
|
||||
assert_eq!(store.history_keep().unwrap(), 3);
|
||||
let id = created(&mut store, profile);
|
||||
let mut model = store.load_current(id).unwrap();
|
||||
for rev in 1..=9u64 {
|
||||
model.progression.xp = rev as u32;
|
||||
store.commit_snapshot(id, rev, model.clone(), "realm-a", None).unwrap();
|
||||
}
|
||||
let revisions: Vec<u64> = store.list_revisions(id).unwrap().iter().map(|r| r.revision).collect();
|
||||
assert_eq!(revisions, vec![8, 9, 10], "only the newest three remain");
|
||||
assert_eq!(store.character(id).unwrap().revision, 10);
|
||||
assert_eq!(store.load_current(id).unwrap().progression.xp, 9);
|
||||
assert!(matches!(store.load_snapshot(id, 1), Err(PortableError::UnknownRevision { .. })));
|
||||
|
||||
// lowering the limit and pruning never removes the current revision
|
||||
store.set_history_keep(1).unwrap();
|
||||
assert_eq!(store.prune(id).unwrap(), 2);
|
||||
assert_eq!(store.list_revisions(id).unwrap().len(), 1);
|
||||
assert_eq!(store.load_current(id).unwrap().progression.xp, 9);
|
||||
assert_eq!(store.prune(id).unwrap(), 0);
|
||||
|
||||
assert!(store.set_history_keep(0).is_err());
|
||||
assert!(store.set_history_keep(MAX_HISTORY_KEEP + 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_damaged_snapshot_is_detected_not_trusted() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
// flip bytes in the stored payload
|
||||
store.conn.execute("UPDATE snapshot SET payload = CAST(substr(payload, 1, 20) || zeroblob(40) AS BLOB) WHERE character_id = ?1", [id.to_string()]).unwrap();
|
||||
assert!(store.load_current(id).is_err());
|
||||
// a payload that decodes but does not match the recorded hash
|
||||
let (mut other, profile) = fresh();
|
||||
let a = created(&mut other, profile);
|
||||
let b = other.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap();
|
||||
other.conn.execute(
|
||||
"UPDATE snapshot SET payload = (SELECT payload FROM snapshot WHERE character_id = ?2) WHERE character_id = ?1",
|
||||
params![a.to_string(), b.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(other.load_current(a), Err(PortableError::CorruptSnapshot(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_survives_reopening_the_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let id;
|
||||
{
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
let profile = store.default_profile().unwrap();
|
||||
id = created(&mut store, profile);
|
||||
let model = store.load_current(id).unwrap();
|
||||
store.commit_snapshot(id, 1, with_money(model, 4242), "realm-a", None).unwrap();
|
||||
store.set_history_keep(7).unwrap();
|
||||
}
|
||||
let mut store = Store::open(dir.path()).unwrap();
|
||||
assert_eq!(store.character(id).unwrap().revision, 2);
|
||||
assert_eq!(store.load_current(id).unwrap().progression.money, 4242);
|
||||
assert_eq!(store.history_keep().unwrap(), 7);
|
||||
let profile = store.default_profile().unwrap();
|
||||
assert_eq!(store.list_characters(profile).unwrap().len(), 1);
|
||||
assert!(Store::path_of(dir.path()).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrations_are_idempotent_and_newer_databases_are_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let store = Store::open(dir.path()).unwrap();
|
||||
assert_eq!(store.schema_version().unwrap(), MIGRATIONS.len() as i64);
|
||||
}
|
||||
// opening again changes nothing
|
||||
assert_eq!(Store::open(dir.path()).unwrap().schema_version().unwrap(), MIGRATIONS.len() as i64);
|
||||
|
||||
// a database written by a newer Manager is refused, not "migrated" downwards
|
||||
{
|
||||
let conn = Connection::open(Store::path_of(dir.path())).unwrap();
|
||||
conn.pragma_update(None, "user_version", 99).unwrap();
|
||||
}
|
||||
assert!(matches!(Store::open(dir.path()), Err(PortableError::NewerDatabase { found: 99, .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_sqlite_file_is_not_adopted() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let file = dir.path().join(DATABASE_FILE);
|
||||
{
|
||||
let conn = Connection::open(&file).unwrap();
|
||||
conn.execute_batch("CREATE TABLE somebody_elses (x INTEGER); INSERT INTO somebody_elses VALUES (1);").unwrap();
|
||||
}
|
||||
assert!(matches!(Store::open(dir.path()), Err(PortableError::NotPortableDatabase)));
|
||||
let conn = Connection::open(&file).unwrap();
|
||||
let rows: i64 = conn.query_row("SELECT count(*) FROM somebody_elses", [], |r| r.get(0)).unwrap();
|
||||
assert_eq!(rows, 1, "the foreign file is left alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_keys_are_enforced() {
|
||||
let (store, _) = fresh();
|
||||
let result = store.conn.execute(
|
||||
"INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at) VALUES (?1, 'r', 1, 1, 'pending', 'x')",
|
||||
[CharacterId::new().to_string()],
|
||||
);
|
||||
assert!(result.is_err(), "a mapping for a character that does not exist must be impossible");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_unique_constraints() {
|
||||
let (mut store, profile) = fresh();
|
||||
let a = created(&mut store, profile);
|
||||
let b = store.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap();
|
||||
|
||||
store.bind_server(a, "realm-a", 154, 1, MappingState::Active).unwrap();
|
||||
assert_eq!(store.find_by_local("realm-a", 154).unwrap(), Some(a));
|
||||
assert_eq!(store.find_by_local("realm-a", 155).unwrap(), None);
|
||||
assert_eq!(store.find_by_local("realm-z", 154).unwrap(), None, "guids are per realm");
|
||||
|
||||
// the same local guid cannot be claimed by another portable character
|
||||
let taken = store.bind_server(b, "realm-a", 154, 1, MappingState::Active);
|
||||
assert!(matches!(taken, Err(PortableError::LocalGuidTaken { local_guid: 154, .. })), "{taken:?}");
|
||||
assert_eq!(store.find_by_local("realm-a", 154).unwrap(), Some(a));
|
||||
|
||||
// but the same number on another realm is a different character
|
||||
store.bind_server(b, "realm-b", 154, 1, MappingState::Pending).unwrap();
|
||||
assert_eq!(store.find_by_local("realm-b", 154).unwrap(), Some(b));
|
||||
|
||||
// one character, many realms, different local guids (the point of the whole design)
|
||||
store.bind_server(a, "realm-b", 8421, 1, MappingState::Pending).unwrap();
|
||||
store.bind_server(a, "realm-c", 391, 1, MappingState::Pending).unwrap();
|
||||
let guids: Vec<(String, u32)> = store.server_mappings(a).unwrap().into_iter().map(|m| (m.server_id, m.local_guid)).collect();
|
||||
assert_eq!(guids, vec![("realm-a".into(), 154), ("realm-b".into(), 8421), ("realm-c".into(), 391)]);
|
||||
|
||||
// rebinding updates in place
|
||||
store.bind_server(a, "realm-a", 154, 1, MappingState::Synced).unwrap();
|
||||
assert_eq!(store.server_mappings(a).unwrap()[0].state, MappingState::Synced);
|
||||
|
||||
// a binding cannot name a revision that does not exist yet, nor an unknown character
|
||||
assert!(store.bind_server(a, "realm-a", 154, 5, MappingState::Synced).is_err());
|
||||
assert!(matches!(store.bind_server(CharacterId::new(), "realm-a", 1, 1, MappingState::Pending), Err(PortableError::UnknownCharacter(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_mapping_unique_per_character_and_server() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let model = store.load_current(id).unwrap();
|
||||
let items: Vec<PortableItemId> = model.items.iter().take(4).map(|i| i.id).collect();
|
||||
|
||||
// an item mapping needs a server binding first
|
||||
assert!(store.set_item_mappings(id, "realm-a", &[(items[0], 1)]).is_err());
|
||||
store.bind_server(id, "realm-a", 154, 1, MappingState::Active).unwrap();
|
||||
|
||||
store.set_item_mappings(id, "realm-a", &[(items[0], 9001), (items[1], 9002), (items[2], 9003)]).unwrap();
|
||||
assert_eq!(store.item_mappings(id, "realm-a").unwrap(), vec![(items[0], 9001), (items[1], 9002), (items[2], 9003)]);
|
||||
|
||||
// UNIQUE(character_id, server_id, local_item_guid): one local item guid cannot stand for two portable items
|
||||
let clash = store.set_item_mappings(id, "realm-a", &[(items[0], 9001), (items[1], 9001)]);
|
||||
assert!(matches!(clash, Err(PortableError::ItemGuidConflict { local_item_guid: 9001, .. })), "{clash:?}");
|
||||
// and the same portable item cannot have two local guids
|
||||
assert!(store.set_item_mappings(id, "realm-a", &[(items[0], 1), (items[0], 2)]).is_err());
|
||||
// a failed replacement leaves the previous mappings intact
|
||||
assert_eq!(store.item_mappings(id, "realm-a").unwrap().len(), 3);
|
||||
|
||||
// the database itself enforces the same rule, not just the Rust check
|
||||
let direct = store.conn.execute(
|
||||
"INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid) VALUES (?1, 'realm-a', ?2, 9001)",
|
||||
params![id.to_string(), items[3].to_string()],
|
||||
);
|
||||
assert!(direct.is_err(), "UNIQUE(character_id, server_id, local_item_guid)");
|
||||
|
||||
// the same local item guid on another realm is fine
|
||||
store.bind_server(id, "realm-b", 8421, 1, MappingState::Active).unwrap();
|
||||
store.set_item_mappings(id, "realm-b", &[(items[0], 9001)]).unwrap();
|
||||
assert_eq!(store.item_mappings(id, "realm-b").unwrap(), vec![(items[0], 9001)]);
|
||||
|
||||
// if the local character changes (deleted and imported again), the old item guids are meaningless
|
||||
store.bind_server(id, "realm-a", 777, 1, MappingState::Active).unwrap();
|
||||
assert!(store.item_mappings(id, "realm-a").unwrap().is_empty());
|
||||
assert_eq!(store.item_mappings(id, "realm-b").unwrap().len(), 1, "other realms are untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collection_union_never_removes_and_unchanged_means_no_revision() {
|
||||
let (mut store, profile) = fresh();
|
||||
let kind = "coa:wardrobe";
|
||||
assert!(store.collection(profile, kind).unwrap().is_none());
|
||||
|
||||
// nothing stored, nothing incoming: nothing is created
|
||||
let empty = store.merge_collection(profile, kind, &IdSet::new()).unwrap();
|
||||
assert!(!empty.changed && empty.info.is_none());
|
||||
|
||||
let first = store.merge_collection(profile, kind, &IdSet::from_ids([10, 20, 30]).unwrap()).unwrap();
|
||||
assert!(first.changed);
|
||||
assert_eq!(first.added, 3);
|
||||
let info1 = first.info.unwrap();
|
||||
assert_eq!((info1.revision, info1.count), (1, 3));
|
||||
|
||||
// another realm returns a subset: nothing is lost, nothing changes
|
||||
let subset = store.merge_collection(profile, kind, &IdSet::from_ids([20]).unwrap()).unwrap();
|
||||
assert!(!subset.changed);
|
||||
assert_eq!(subset.info.as_ref().unwrap(), &info1, "same revision, same hash");
|
||||
let same = store.merge_collection(profile, kind, &IdSet::from_ids([10, 20, 30]).unwrap()).unwrap();
|
||||
assert!(!same.changed);
|
||||
|
||||
// a realm with new unlocks (and without the old ones) only adds
|
||||
let more = store.merge_collection(profile, kind, &IdSet::from_ids([30, 40, 5]).unwrap()).unwrap();
|
||||
assert!(more.changed);
|
||||
assert_eq!(more.added, 2);
|
||||
let info2 = more.info.unwrap();
|
||||
assert_eq!((info2.revision, info2.count), (2, 5));
|
||||
assert_ne!(info2.hash, info1.hash);
|
||||
|
||||
let (info, set) = store.collection(profile, kind).unwrap().unwrap();
|
||||
assert_eq!(set.ids(), &[5, 10, 20, 30, 40]);
|
||||
assert_eq!(info, info2);
|
||||
assert_eq!(store.collection_info(profile, kind).unwrap().unwrap(), info2);
|
||||
|
||||
// kinds are separate collections
|
||||
assert!(store.collection_info(profile, "coa:vanity").unwrap().is_none());
|
||||
assert!(store.merge_collection(profile, "wardrobe", &IdSet::new()).is_err(), "kinds must be namespaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collections_hold_tens_of_thousands_of_ids_compactly() {
|
||||
let (mut store, profile) = fresh();
|
||||
for (kind, count) in [("coa:wardrobe", 2_000u32), ("coa:vanity", 10_000), ("mod:test:mounts", 50_000)] {
|
||||
let ids: Vec<u32> = (0..count).map(|n| n * 7 + (n % 5)).collect();
|
||||
let set = IdSet::from_ids(ids).unwrap();
|
||||
let merge = store.merge_collection(profile, kind, &set).unwrap();
|
||||
assert_eq!(merge.added, count as usize);
|
||||
let stored_bytes: i64 = store.conn.query_row("SELECT length(payload) FROM collection WHERE kind = ?1", [kind], |r| r.get(0)).unwrap();
|
||||
assert!(stored_bytes < count as i64 * 2 + 16, "{kind}: {count} ids took {stored_bytes} bytes");
|
||||
let (_, back) = store.collection(profile, kind).unwrap().unwrap();
|
||||
assert_eq!(back, set);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_collection_is_detected() {
|
||||
let (mut store, profile) = fresh();
|
||||
store.merge_collection(profile, "coa:vanity", &IdSet::from_ids([1, 2, 3]).unwrap()).unwrap();
|
||||
let forged = IdSet::from_ids([1, 2, 3, 4]).unwrap().encode();
|
||||
store.conn.execute("UPDATE collection SET payload = ?1", [forged]).unwrap();
|
||||
assert!(matches!(store.collection(profile, "coa:vanity"), Err(PortableError::CorruptSnapshot(_))));
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//! Format and protocol versions. They exist from the first commit so that nothing has to be guessed later.
|
||||
//!
|
||||
//! A reader refuses data whose version is **newer** than it knows; older versions are read through a migration.
|
||||
//! The registry and relay versions are not used by anything yet (Phases 10 and 13); they live here so there is one
|
||||
//! place that lists every version the portable system speaks.
|
||||
|
||||
/// Version of the serialised [`PortableCharacter`](super::model::PortableCharacter).
|
||||
pub const PORTABLE_CHARACTER_FORMAT_VERSION: u32 = 1;
|
||||
/// Version of the serialised collection set ([`IdSet`](super::collection::IdSet)).
|
||||
pub const PORTABLE_COLLECTION_FORMAT_VERSION: u32 = 1;
|
||||
/// Version of the extension container ([`Extension`](super::model::Extension)).
|
||||
pub const EXTENSION_FORMAT_VERSION: u32 = 1;
|
||||
/// Version of the snapshot envelope (header + compressed payload) that is stored and, later, transferred.
|
||||
pub const SNAPSHOT_FORMAT_VERSION: u32 = 1;
|
||||
/// Registry API protocol version (Phase 10).
|
||||
pub const REGISTRY_PROTOCOL_VERSION: u32 = 1;
|
||||
/// Relay protocol version (Phase 13).
|
||||
pub const RELAY_PROTOCOL_VERSION: u32 = 1;
|
||||
@@ -0,0 +1,91 @@
|
||||
# Portable character format (Phase 1)
|
||||
|
||||
What is stored locally and how it is serialised. Code: `crates/coa-core/src/portable/`. Reasoning:
|
||||
`PORTABLE_CHARACTERS_AUDIT.md`.
|
||||
|
||||
**Status:** format version 1 is *provisional* until the Phase 3 gate (Realm A -> Manager -> Realm B) passes. Until then
|
||||
a change of shape may be made without a migration because nothing outside development machines holds version 1 data.
|
||||
After that gate every change bumps the version.
|
||||
|
||||
## Versions (`versions.rs`)
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `PORTABLE_CHARACTER_FORMAT_VERSION` | 1 | the serialised `PortableCharacter` |
|
||||
| `PORTABLE_COLLECTION_FORMAT_VERSION` | 1 | the encoded id set of a collection |
|
||||
| `EXTENSION_FORMAT_VERSION` | 1 | the extension container |
|
||||
| `SNAPSHOT_FORMAT_VERSION` | 1 | the stored snapshot envelope (compressed canonical JSON) |
|
||||
| `REGISTRY_PROTOCOL_VERSION` | 1 | reserved for Phase 10 |
|
||||
| `RELAY_PROTOCOL_VERSION` | 1 | reserved for Phase 13 |
|
||||
|
||||
A reader **refuses** data whose version is newer than it knows (checked before the shape is parsed), and reads older
|
||||
versions through a migration.
|
||||
|
||||
## Identity
|
||||
|
||||
* `CharacterId`, `ProfileId`, `PortableItemId`, `PortablePetId` are **UUIDv7**, generated locally. Only version-7 ids
|
||||
are accepted from text or JSON; a v4 or nil UUID is rejected as foreign.
|
||||
* Local AzerothCore guids never appear in the model. They exist only in the mapping tables
|
||||
(`character_server_mapping`, `item_mapping`), per realm.
|
||||
* Game content is addressed as `<namespace>:<kind>:<id>` (`ContentId`), e.g. `core:wotlk:item:19019`, `coa:class:12`,
|
||||
`mod:<module>:item:7`. The last two segments are kind and number; everything before is the namespace. Plain numbers
|
||||
elsewhere in the model (spells, quests, factions, skills) are interpreted in the character's `content_namespace`.
|
||||
|
||||
## Canonical form and hash
|
||||
|
||||
1. The model is **normalised**: every set is sorted and de-duplicated (spells, talents, skills, glyph sets, items by
|
||||
container/slot, quests, reputation, action buttons, pets and their spells, enchantments).
|
||||
2. It is serialised as compact JSON with a fixed field order (`serde`), unknown fields refused on read.
|
||||
3. `content_hash = SHA-256(canonical JSON)`. Equal characters have equal hashes; the hash does not depend on the
|
||||
compressor.
|
||||
4. The stored payload is `zstd(canonical JSON)`.
|
||||
|
||||
## Limits (hostile input)
|
||||
|
||||
`model::limits` caps every list and string; the snapshot is capped at 16 MiB decompressed and 8 MiB compressed, and
|
||||
decompression stops at the cap while reading (decompression bombs). Money is capped at the realm's own maximum
|
||||
(2,147,483,646 copper). Items must form a valid container tree (containers directly on the character, no two items in
|
||||
one place, no duplicate ids). Extension payloads must match their recorded hash.
|
||||
|
||||
## Revisions
|
||||
|
||||
* `character.revision` is the canonical revision; freshness is decided by this integer, never by a timestamp.
|
||||
* `commit_snapshot(expected_revision, ...)` succeeds only if `expected_revision` equals the canonical revision
|
||||
(`StaleRevision` otherwise) and creates revision + 1, in one `BEGIN IMMEDIATE` transaction.
|
||||
* `rollback_to` creates a **new** revision whose state equals an old one; revision numbers never decrease.
|
||||
* The newest `history_keep` revisions are kept (default 20, 1..=1000, configurable). The current revision is never
|
||||
pruned.
|
||||
* A character's `ruleset` (`coa` | `wildcard`) can never change.
|
||||
|
||||
## Extensions
|
||||
|
||||
`extensions` maps an extension namespace (`mod:<module>`) to `{module_version, format_version, content_hash, payload}`.
|
||||
The payload is opaque bytes: it is stored and returned unchanged, whether or not this Manager understands it. A realm
|
||||
that does not support an extension simply does not receive it; the canonical copy is kept.
|
||||
|
||||
## Collections
|
||||
|
||||
Account-wide sets of numeric ids (`coa:wardrobe`, `coa:vanity`, ...), stored per profile and kind:
|
||||
|
||||
* union only: `stored = stored U incoming`; nothing is ever removed because a realm did not return it;
|
||||
* if the union equals what is stored, **no revision is created** and the hash is unchanged;
|
||||
* encoding: `[format version][count][first id][gap]...`, unsigned LEB128 varints, strictly ascending ids, 1-2 bytes per
|
||||
id for realistic sets (50,000 scattered ids < 100 KB);
|
||||
* `collection_hash = SHA-256("coa-collection-v1\0" + kind + "\0" + encoding)`.
|
||||
|
||||
## Store (`%LOCALAPPDATA%\CoAServerManager\portable\portable.db`)
|
||||
|
||||
SQLite, WAL, `synchronous=FULL`, `foreign_keys=ON`, `application_id = 0x434F4150`, forward-only migrations tracked in
|
||||
`PRAGMA user_version`. A database from a newer Manager, or a foreign SQLite file, is refused and left untouched.
|
||||
|
||||
| Table | Purpose |
|
||||
|---|---|
|
||||
| `profile` | the owner |
|
||||
| `character` | summary row + canonical revision |
|
||||
| `snapshot` | `(character_id, revision)` -> compressed canonical payload + hash |
|
||||
| `character_server_mapping` | `(character_id, server_id)` -> local guid; `UNIQUE(server_id, local_guid)` |
|
||||
| `item_mapping` | `(character_id, server_id, portable_item_id)` -> local item guid; `UNIQUE(character_id, server_id, local_item_guid)` |
|
||||
| `collection` | per profile and kind: revision, hash, encoded set |
|
||||
| `setting` | `history_keep` |
|
||||
|
||||
The store never reads or writes a realm database and never leaves the machine.
|
||||
Reference in new issue
Block a user