mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 11:51:03 +00:00
portable: Phase 4 core - three-way merge, persistent baselines/pet mappings, in-place update, session reconcile
merge3(target, base, ours) applies only the base->ours delta; session reconcile is (C0, B0, B1, lenient), in-place update is (realm now, synced snapshot, new canonical, strict). Item/pet mappings gain presence (present/filtered/realm_local) so realm-filtered state is told from player deletion. Updates are journaled like imports (marker + alloc row) and recovered from the realm. Live tests cover filtered vs deleted items, checkpoints, pets, conflicts, lost answers, atomic rollback and the A->B->A->B round trip without a server. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e8f99c16b7
commit
9210be98b5
22 files changed
+4088
-124
No files matched your search
@@ -57,6 +57,13 @@ pub enum PortableError {
|
||||
#[error("import {import_id} needs attention: {detail}")]
|
||||
ImportNeedsAttention { import_id: ImportId, detail: String },
|
||||
|
||||
#[error("this character has no open session on this realm: capture the baseline after the realm's first load and before it is played")]
|
||||
NoBaseline,
|
||||
#[error("this character has an open session on this realm: reconcile it first")]
|
||||
SessionOpen,
|
||||
#[error("the character was changed on the realm and in the canonical store in conflicting ways; nothing was written: {}", .0.join("; "))]
|
||||
UpdateConflicts(Vec<String>),
|
||||
|
||||
#[error("invalid data: {0}")]
|
||||
Invalid(String),
|
||||
#[error("limit exceeded: {0}")]
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The three-way merge of portable characters.
|
||||
//!
|
||||
//! `merge3(target, base, ours)` applies **what changed between `base` and `ours`** onto `target`, and nothing else. It is
|
||||
//! the one engine behind both directions of a round trip:
|
||||
//!
|
||||
//! | use | target | base | ours |
|
||||
//! |---|---|---|---|
|
||||
//! | **session reconcile** (realm -> canonical) | `C0` canonical at join | `B0` the realm after its own first load/save normalisation, before any progression | `B1` the realm now |
|
||||
//! | **in-place update** (canonical -> realm) | `A_now` the realm's current state | `S` the canonical snapshot the realm was last synced with | `C_new` the new canonical |
|
||||
//!
|
||||
//! Only the `base -> ours` delta is "progress". Whatever differs between `target` and `base` already (the realm's own
|
||||
//! defaults, DBC removals, mailed items, daily honor resets; or, in the other direction, the realm's own additions) is
|
||||
//! **left exactly as it is in `target`**: automatic normalisation never overwrites the character it normalised.
|
||||
//!
|
||||
//! Rules:
|
||||
//! * scalars: `ours` wins where `base != ours`; otherwise `target` is kept;
|
||||
//! * accumulators (money, honor totals, kills, arena points, xp inside a level): the *difference* `ours - base` is added;
|
||||
//! * bit sets (known currencies, titles, explored zones, taxi nodes): bits gained are added, bits lost are removed;
|
||||
//! * keyed collections (spells, skills, reputation, quests, action buttons, settings, ...): keys added in `ours` are added,
|
||||
//! keys removed in `ours` are removed, values changed in `ours` are replaced;
|
||||
//! * items and pets are matched by their **portable id**: present in `target` and `base`: delta applied, or removed when
|
||||
//! `ours` lacks it (the player got rid of it); in `target` only (filtered away by the realm before `base`): **kept**, or
|
||||
//! taken from `ours` if it has come back; in `base` only (the realm's own addition / filtered at the target): ignored;
|
||||
//! new in `ours`: added.
|
||||
//!
|
||||
//! [`Mode::Strict`] additionally reports a **conflict** when `target` and `ours` both departed from `base` and disagree;
|
||||
//! [`Mode::Lenient`] (sessions) takes `ours`.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use std::fmt::Debug;
|
||||
|
||||
use super::error::{PortableError, Result};
|
||||
use super::ids::{PortableItemId, PortablePetId};
|
||||
use super::model::*;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Mode {
|
||||
/// Take `ours` where it changed; never report conflicts.
|
||||
Lenient,
|
||||
/// Report a conflict where `target` and `ours` both changed the same thing differently.
|
||||
Strict,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Conflict {
|
||||
pub path: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Merged {
|
||||
pub model: PortableCharacter,
|
||||
/// What was applied (`base -> ours`).
|
||||
pub changes: Vec<String>,
|
||||
/// What differs between `target` and `base` and was therefore deliberately left alone.
|
||||
pub left_alone: Vec<String>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
pub items: ItemOutcome,
|
||||
pub pets: PetOutcome,
|
||||
}
|
||||
|
||||
/// How the items fell out, by portable id.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ItemOutcome {
|
||||
/// New in `ours`.
|
||||
pub added: Vec<PortableItemId>,
|
||||
/// Present in `target` and `base`, gone from `ours`: the player got rid of them.
|
||||
pub removed: Vec<PortableItemId>,
|
||||
/// Present in all three and different in `ours`.
|
||||
pub changed: Vec<PortableItemId>,
|
||||
/// In `target` only and still absent from `ours`: filtered away by the realm, kept.
|
||||
pub filtered_kept: Vec<PortableItemId>,
|
||||
/// In `target` only and back in `ours`.
|
||||
pub reappeared: Vec<PortableItemId>,
|
||||
/// In `base` only: the realm's own additions (or filtered at the target), never merged.
|
||||
pub realm_local: Vec<PortableItemId>,
|
||||
/// Items moved to a free slot because the place they were in was taken.
|
||||
pub relocated: Vec<PortableItemId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PetOutcome {
|
||||
pub added: Vec<PortablePetId>,
|
||||
pub removed: Vec<PortablePetId>,
|
||||
pub changed: Vec<PortablePetId>,
|
||||
pub filtered_kept: Vec<PortablePetId>,
|
||||
pub reappeared: Vec<PortablePetId>,
|
||||
pub realm_local: Vec<PortablePetId>,
|
||||
}
|
||||
|
||||
struct Ctx {
|
||||
mode: Mode,
|
||||
conflicts: Vec<Conflict>,
|
||||
changes: Vec<String>,
|
||||
left_alone: Vec<String>,
|
||||
}
|
||||
|
||||
fn short<T: Debug>(v: &T) -> String {
|
||||
let s = format!("{v:?}");
|
||||
if s.len() > 60 {
|
||||
format!("{}...", s.chars().take(57).collect::<String>())
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn conflict<T: Debug>(&mut self, path: &str, target: &T, base: &T, ours: &T) {
|
||||
self.conflicts.push(Conflict { path: path.to_string(), detail: format!("target {} / base {} / ours {}", short(target), short(base), short(ours)) });
|
||||
}
|
||||
|
||||
/// `ours` wins where it moved away from `base`.
|
||||
fn scalar<T: PartialEq + Clone + Debug>(&mut self, path: &str, target: &mut T, base: &T, ours: &T) {
|
||||
if base == ours {
|
||||
if target != base {
|
||||
self.left_alone.push(format!("{path}: realm-side {} (was {})", short(target), short(base)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.mode == Mode::Strict && target != base && target != ours {
|
||||
self.conflict(path, target, base, ours);
|
||||
return;
|
||||
}
|
||||
self.changes.push(format!("{path}: {} -> {}", short(base), short(ours)));
|
||||
*target = ours.clone();
|
||||
}
|
||||
|
||||
/// `target + (ours - base)`, clamped to `0..=max`.
|
||||
fn add(&mut self, path: &str, target: &mut u64, base: u64, ours: u64, max: u64) {
|
||||
if base == ours {
|
||||
return;
|
||||
}
|
||||
let value = (*target as i128 + ours as i128 - base as i128).clamp(0, max as i128) as u64;
|
||||
self.changes.push(format!("{path}: {} {:+} -> {value}", *target, ours as i128 - base as i128));
|
||||
*target = value;
|
||||
}
|
||||
|
||||
/// Bit sets in words: `(target | gained) & !lost`.
|
||||
fn bit_words(&mut self, path: &str, target: &mut String, base: &str, ours: &str) {
|
||||
if base == ours {
|
||||
return;
|
||||
}
|
||||
let parse = |s: &str| s.split_whitespace().map(|w| w.parse::<u64>()).collect::<std::result::Result<Vec<u64>, _>>();
|
||||
let (Ok(t), Ok(b), Ok(o)) = (parse(target), parse(base), parse(ours)) else {
|
||||
return self.scalar(path, target, &base.to_string(), &ours.to_string());
|
||||
};
|
||||
let words = t.len().max(b.len()).max(o.len());
|
||||
let get = |v: &[u64], i: usize| v.get(i).copied().unwrap_or(0);
|
||||
let merged: Vec<u64> = (0..words).map(|i| (get(&t, i) | (get(&o, i) & !get(&b, i))) & !(get(&b, i) & !get(&o, i))).collect();
|
||||
let text: String = merged.iter().map(|w| format!("{w} ")).collect();
|
||||
if text.trim() != target.trim() {
|
||||
self.changes.push(format!("{path}: bits changed"));
|
||||
}
|
||||
*target = text;
|
||||
}
|
||||
|
||||
/// Keyed values: added / removed / changed keys of `ours` relative to `base` are applied to `target`.
|
||||
fn map<K: Ord + Clone + Debug, V: PartialEq + Clone + Debug>(&mut self, path: &str, target: &mut BTreeMap<K, V>, base: &BTreeMap<K, V>, ours: &BTreeMap<K, V>) {
|
||||
for (k, o) in ours {
|
||||
match base.get(k) {
|
||||
None => {
|
||||
// added in ours
|
||||
if let Some(t) = target.get(k) {
|
||||
if self.mode == Mode::Strict && t != o {
|
||||
self.conflicts.push(Conflict { path: format!("{path}[{k:?}]"), detail: format!("target {} / ours {} (not in base)", short(t), short(o)) });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if target.get(k) != Some(o) {
|
||||
self.changes.push(format!("{path}[{k:?}]: added"));
|
||||
}
|
||||
target.insert(k.clone(), o.clone());
|
||||
}
|
||||
Some(b) if b != o => {
|
||||
if self.mode == Mode::Strict {
|
||||
if let Some(t) = target.get(k) {
|
||||
if t != b && t != o {
|
||||
self.conflict(&format!("{path}[{k:?}]"), t, b, o);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.changes.push(format!("{path}[{k:?}]: {} -> {}", short(b), short(o)));
|
||||
target.insert(k.clone(), o.clone());
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (k, b) in base {
|
||||
if ours.contains_key(k) {
|
||||
continue;
|
||||
}
|
||||
// removed in ours
|
||||
match target.get(k) {
|
||||
Some(t) if self.mode == Mode::Strict && t != b => self.conflicts.push(Conflict { path: format!("{path}[{k:?}]"), detail: format!("removed in ours but changed in target ({})", short(t)) }),
|
||||
Some(_) => {
|
||||
self.changes.push(format!("{path}[{k:?}]: removed"));
|
||||
target.remove(k);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
// what the target has on its own (not in base): left alone, counted for the report
|
||||
let own = target.keys().filter(|k| !base.contains_key(*k) && !ours.contains_key(*k)).count();
|
||||
let lost = base.keys().filter(|k| !target.contains_key(*k) && ours.contains_key(*k)).count();
|
||||
if own > 0 {
|
||||
self.left_alone.push(format!("{path}: {own} entr{} only on the target side", if own == 1 { "y" } else { "ies" }));
|
||||
}
|
||||
if lost > 0 {
|
||||
self.left_alone.push(format!("{path}: {lost} entr{} the target side does not have", if lost == 1 { "y" } else { "ies" }));
|
||||
}
|
||||
}
|
||||
|
||||
fn set<K: Ord + Clone + Debug>(&mut self, path: &str, target: &mut BTreeSet<K>, base: &BTreeSet<K>, ours: &BTreeSet<K>) {
|
||||
let mut t: BTreeMap<K, ()> = target.iter().map(|k| (k.clone(), ())).collect();
|
||||
let b: BTreeMap<K, ()> = base.iter().map(|k| (k.clone(), ())).collect();
|
||||
let o: BTreeMap<K, ()> = ours.iter().map(|k| (k.clone(), ())).collect();
|
||||
self.map(path, &mut t, &b, &o);
|
||||
*target = t.into_keys().collect();
|
||||
}
|
||||
}
|
||||
|
||||
fn to_map<T, K: Ord, V>(items: &[T], f: impl Fn(&T) -> (K, V)) -> BTreeMap<K, V> {
|
||||
items.iter().map(f).collect()
|
||||
}
|
||||
|
||||
/// Apply `base -> ours` onto `target`.
|
||||
pub fn merge3(target: &PortableCharacter, base: &PortableCharacter, ours: &PortableCharacter, mode: Mode) -> Result<Merged> {
|
||||
if target.ruleset != base.ruleset || target.ruleset != ours.ruleset {
|
||||
return Err(PortableError::Invalid("three characters of different rulesets cannot be merged".into()));
|
||||
}
|
||||
let mut cx = Ctx { mode, conflicts: Vec::new(), changes: Vec::new(), left_alone: Vec::new() };
|
||||
let mut out = target.clone();
|
||||
|
||||
// ---- identity ------------------------------------------------------------------------------------------------
|
||||
cx.scalar("identity.name", &mut out.identity.name, &base.identity.name, &ours.identity.name);
|
||||
cx.scalar("identity.race", &mut out.identity.race, &base.identity.race, &ours.identity.race);
|
||||
cx.scalar("identity.class", &mut out.identity.class, &base.identity.class, &ours.identity.class);
|
||||
cx.scalar("identity.gender", &mut out.identity.gender, &base.identity.gender, &ours.identity.gender);
|
||||
cx.scalar("identity.appearance", &mut out.identity.appearance, &base.identity.appearance, &ours.identity.appearance);
|
||||
cx.scalar("identity.cosmetic_flags", &mut out.identity.cosmetic_flags, &base.identity.cosmetic_flags, &ours.identity.cosmetic_flags);
|
||||
|
||||
// ---- progression ---------------------------------------------------------------------------------------------
|
||||
let (t, b, o) = (&mut out.progression, &base.progression, &ours.progression);
|
||||
let leveled = b.level != o.level;
|
||||
cx.scalar("progression.level", &mut t.level, &b.level, &o.level);
|
||||
if leveled {
|
||||
cx.scalar("progression.xp", &mut t.xp, &b.xp, &o.xp);
|
||||
} else {
|
||||
let mut xp = t.xp as u64;
|
||||
cx.add("progression.xp", &mut xp, b.xp as u64, o.xp as u64, u32::MAX as u64);
|
||||
t.xp = xp as u32;
|
||||
}
|
||||
macro_rules! accumulate {
|
||||
($path:literal, $field:expr, $b:expr, $o:expr, $max:expr) => {{
|
||||
let mut v = $field as u64;
|
||||
cx.add($path, &mut v, $b as u64, $o as u64, $max as u64);
|
||||
$field = v as _;
|
||||
}};
|
||||
}
|
||||
accumulate!("progression.money", t.money, b.money, o.money, limits::MAX_MONEY);
|
||||
accumulate!("progression.honor.arena_points", t.honor.arena_points, b.honor.arena_points, o.honor.arena_points, u32::MAX);
|
||||
accumulate!("progression.honor.total_honor", t.honor.total_honor, b.honor.total_honor, o.honor.total_honor, u32::MAX);
|
||||
accumulate!("progression.honor.total_kills", t.honor.total_kills, b.honor.total_kills, o.honor.total_kills, u32::MAX);
|
||||
// the daily counters are reset by the realm at midnight: the realm's current value is the truth when it moved
|
||||
cx.scalar("progression.honor.today_honor", &mut t.honor.today_honor, &b.honor.today_honor, &o.honor.today_honor);
|
||||
cx.scalar("progression.honor.yesterday_honor", &mut t.honor.yesterday_honor, &b.honor.yesterday_honor, &o.honor.yesterday_honor);
|
||||
cx.scalar("progression.honor.today_kills", &mut t.honor.today_kills, &b.honor.today_kills, &o.honor.today_kills);
|
||||
cx.scalar("progression.honor.yesterday_kills", &mut t.honor.yesterday_kills, &b.honor.yesterday_kills, &o.honor.yesterday_kills);
|
||||
{
|
||||
let (mut tv, bv, ov) = (t.known_currencies, b.known_currencies, o.known_currencies);
|
||||
if bv != ov {
|
||||
let merged = (tv | (ov & !bv)) & !(bv & !ov);
|
||||
if merged != tv {
|
||||
cx.changes.push(format!("progression.known_currencies: bits {bv:#x} -> {ov:#x}"));
|
||||
}
|
||||
tv = merged;
|
||||
}
|
||||
t.known_currencies = tv;
|
||||
}
|
||||
cx.scalar("progression.chosen_title", &mut t.chosen_title, &b.chosen_title, &o.chosen_title);
|
||||
cx.bit_words("progression.known_titles", &mut t.known_titles, &b.known_titles, &o.known_titles);
|
||||
cx.bit_words("progression.explored_zones", &mut t.explored_zones, &b.explored_zones, &o.explored_zones);
|
||||
cx.bit_words("progression.taxi_mask", &mut t.taxi_mask, &b.taxi_mask, &o.taxi_mask);
|
||||
cx.scalar("progression.bank_slots", &mut t.bank_slots, &b.bank_slots, &o.bank_slots);
|
||||
cx.scalar("progression.stable_slots", &mut t.stable_slots, &b.stable_slots, &o.stable_slots);
|
||||
cx.scalar("progression.watched_faction", &mut t.watched_faction, &b.watched_faction, &o.watched_faction);
|
||||
cx.scalar("progression.action_bars_mask", &mut t.action_bars_mask, &b.action_bars_mask, &o.action_bars_mask);
|
||||
|
||||
// ---- build ---------------------------------------------------------------------------------------------------
|
||||
{
|
||||
let mut spells = to_map(&out.build.spells, |(s, m)| (*s, *m));
|
||||
cx.map("build.spells", &mut spells, &to_map(&base.build.spells, |(s, m)| (*s, *m)), &to_map(&ours.build.spells, |(s, m)| (*s, *m)));
|
||||
out.build.spells = spells.into_iter().collect();
|
||||
|
||||
let mut skills = to_map(&out.build.skills, |s| (s.skill, (s.value, s.max)));
|
||||
cx.map("build.skills", &mut skills, &to_map(&base.build.skills, |s| (s.skill, (s.value, s.max))), &to_map(&ours.build.skills, |s| (s.skill, (s.value, s.max))));
|
||||
out.build.skills = skills.into_iter().map(|(skill, (value, max))| Skill { skill, value, max }).collect();
|
||||
|
||||
let mut glyphs = to_map(&out.build.glyphs, |g| (g.talent_group, g.glyphs));
|
||||
cx.map("build.glyphs", &mut glyphs, &to_map(&base.build.glyphs, |g| (g.talent_group, g.glyphs)), &to_map(&ours.build.glyphs, |g| (g.talent_group, g.glyphs)));
|
||||
out.build.glyphs = glyphs.into_iter().map(|(talent_group, glyphs)| GlyphSet { talent_group, glyphs }).collect();
|
||||
|
||||
// stock talents are never written to a realm (see PORTABLE_IMPORT.md): they are canonical-only and never merged
|
||||
cx.scalar("build.talent_groups_count", &mut out.build.talent_groups_count, &base.build.talent_groups_count, &ours.build.talent_groups_count);
|
||||
cx.scalar("build.active_talent_group", &mut out.build.active_talent_group, &base.build.active_talent_group, &ours.build.active_talent_group);
|
||||
cx.scalar("build.extra_bonus_talent_count", &mut out.build.extra_bonus_talent_count, &base.build.extra_bonus_talent_count, &ours.build.extra_bonus_talent_count);
|
||||
cx.scalar("build.reset_talents_cost", &mut out.build.reset_talents_cost, &base.build.reset_talents_cost, &ours.build.reset_talents_cost);
|
||||
}
|
||||
|
||||
// ---- quests, reputation, actions, settings, macros ---------------------------------------------------------------
|
||||
{
|
||||
let mut active = to_map(&out.quests.active, |q| (q.quest, q.clone()));
|
||||
cx.map("quests.active", &mut active, &to_map(&base.quests.active, |q| (q.quest, q.clone())), &to_map(&ours.quests.active, |q| (q.quest, q.clone())));
|
||||
out.quests.active = active.into_values().collect();
|
||||
|
||||
let mut rewarded: BTreeSet<u32> = out.quests.rewarded.iter().copied().collect();
|
||||
cx.set("quests.rewarded", &mut rewarded, &base.quests.rewarded.iter().copied().collect(), &ours.quests.rewarded.iter().copied().collect());
|
||||
out.quests.rewarded = rewarded.into_iter().collect();
|
||||
|
||||
let mut rep = to_map(&out.reputation, |r| (r.faction, (r.standing, r.flags)));
|
||||
cx.map("reputation", &mut rep, &to_map(&base.reputation, |r| (r.faction, (r.standing, r.flags))), &to_map(&ours.reputation, |r| (r.faction, (r.standing, r.flags))));
|
||||
out.reputation = rep.into_iter().map(|(faction, (standing, flags))| ReputationEntry { faction, standing, flags }).collect();
|
||||
|
||||
let mut actions = to_map(&out.actions, |a| ((a.spec, a.button), (a.action, a.kind)));
|
||||
cx.map("actions", &mut actions, &to_map(&base.actions, |a| ((a.spec, a.button), (a.action, a.kind))), &to_map(&ours.actions, |a| ((a.spec, a.button), (a.action, a.kind))));
|
||||
out.actions = actions.into_iter().map(|((spec, button), (action, kind))| ActionButton { spec, button, action, kind }).collect();
|
||||
|
||||
cx.map("settings", &mut out.settings, &base.settings, &ours.settings);
|
||||
cx.map("client_data", &mut out.client_data, &base.client_data, &ours.client_data);
|
||||
}
|
||||
// extensions are the canonical character's own (quarantined data); a realm never contributes to them
|
||||
|
||||
// ---- items and pets ------------------------------------------------------------------------------------------------
|
||||
let items = merge_items(&mut cx, &mut out, target, base, ours)?;
|
||||
let pets = merge_pets(&mut cx, &mut out, target, base, ours);
|
||||
|
||||
out = out.normalized();
|
||||
Ok(Merged { model: out, changes: cx.changes, left_alone: cx.left_alone, conflicts: cx.conflicts, items, pets })
|
||||
}
|
||||
|
||||
fn merge_items(cx: &mut Ctx, out: &mut PortableCharacter, target: &PortableCharacter, base: &PortableCharacter, ours: &PortableCharacter) -> Result<ItemOutcome> {
|
||||
let by_id = |m: &PortableCharacter| -> HashMap<PortableItemId, PortableItem> { m.items.iter().map(|i| (i.id, i.clone())).collect() };
|
||||
let (t, b, o) = (by_id(target), by_id(base), by_id(ours));
|
||||
let mut result: HashMap<PortableItemId, PortableItem> = t.clone();
|
||||
let mut outcome = ItemOutcome::default();
|
||||
let mut touched: HashSet<PortableItemId> = HashSet::new();
|
||||
|
||||
for (id, titem) in &t {
|
||||
match (b.get(id), o.get(id)) {
|
||||
(Some(bitem), Some(oitem)) => {
|
||||
let merged = merge_item(cx, titem, bitem, oitem, &id.to_string());
|
||||
if &merged != titem {
|
||||
outcome.changed.push(*id);
|
||||
touched.insert(*id);
|
||||
cx.changes.push(format!("item {id}: changed"));
|
||||
}
|
||||
result.insert(*id, merged);
|
||||
}
|
||||
(Some(_), None) => {
|
||||
// the target and the base had it, `ours` does not: the player got rid of it
|
||||
outcome.removed.push(*id);
|
||||
cx.changes.push(format!("item {id}: removed ({})", titem.entry));
|
||||
result.remove(id);
|
||||
}
|
||||
(None, Some(oitem)) => {
|
||||
// only the target knew it (filtered away by the realm before the base) and it has come back
|
||||
let mut back = oitem.clone();
|
||||
back.creator_name = titem.creator_name.clone();
|
||||
result.insert(*id, back);
|
||||
outcome.reappeared.push(*id);
|
||||
touched.insert(*id);
|
||||
cx.changes.push(format!("item {id}: reappeared ({})", titem.entry));
|
||||
}
|
||||
(None, None) => outcome.filtered_kept.push(*id),
|
||||
}
|
||||
}
|
||||
for (id, oitem) in &o {
|
||||
if t.contains_key(id) || b.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
result.insert(*id, oitem.clone());
|
||||
outcome.added.push(*id);
|
||||
touched.insert(*id);
|
||||
cx.changes.push(format!("item {id}: new ({})", oitem.entry));
|
||||
}
|
||||
outcome.realm_local = b.keys().filter(|id| !t.contains_key(*id)).copied().collect();
|
||||
if !outcome.filtered_kept.is_empty() {
|
||||
cx.left_alone.push(format!("items: {} item(s) filtered away by the realm stay canonical", outcome.filtered_kept.len()));
|
||||
}
|
||||
if !outcome.realm_local.is_empty() {
|
||||
cx.left_alone.push(format!("items: {} item(s) only on the realm side are not merged", outcome.realm_local.len()));
|
||||
}
|
||||
|
||||
let mut list: Vec<PortableItem> = result.into_values().collect();
|
||||
outcome.relocated = settle_places(&mut list, &touched)?;
|
||||
out.items = list;
|
||||
for v in [&mut outcome.added, &mut outcome.removed, &mut outcome.changed, &mut outcome.filtered_kept, &mut outcome.reappeared, &mut outcome.realm_local, &mut outcome.relocated] {
|
||||
v.sort();
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn merge_item(cx: &mut Ctx, target: &PortableItem, base: &PortableItem, ours: &PortableItem, path: &str) -> PortableItem {
|
||||
let mut out = target.clone();
|
||||
macro_rules! field {
|
||||
($name:ident) => {
|
||||
cx.scalar(&format!("item {path}.{}", stringify!($name)), &mut out.$name, &base.$name, &ours.$name);
|
||||
};
|
||||
}
|
||||
field!(container);
|
||||
field!(slot);
|
||||
field!(count);
|
||||
field!(duration);
|
||||
field!(charges);
|
||||
field!(flags);
|
||||
field!(enchantments);
|
||||
field!(random_property_id);
|
||||
field!(durability);
|
||||
field!(played_time);
|
||||
field!(text);
|
||||
field!(gift);
|
||||
out
|
||||
}
|
||||
|
||||
/// Free places of the character for relocations: backpack, then bank.
|
||||
fn free_place(taken: &HashSet<(Option<PortableItemId>, u8)>) -> Option<u8> {
|
||||
(23..=38u8).chain(39..=66u8).find(|slot| !taken.contains(&(None, *slot)))
|
||||
}
|
||||
|
||||
/// Make the item list valid: containers must exist and be directly on the character, no two items in one place. Items the
|
||||
/// session touched keep their place; the others (typically items the realm filtered away, kept at their canonical place)
|
||||
/// move to a free slot when their place was taken.
|
||||
fn settle_places(items: &mut [PortableItem], touched: &HashSet<PortableItemId>) -> Result<Vec<PortableItemId>> {
|
||||
let mut relocated = Vec::new();
|
||||
let ids: HashSet<PortableItemId> = items.iter().map(|i| i.id).collect();
|
||||
let top: HashSet<PortableItemId> = items.iter().filter(|i| i.container.is_none()).map(|i| i.id).collect();
|
||||
let mut taken: HashSet<(Option<PortableItemId>, u8)> = HashSet::new();
|
||||
// a stable order: touched first, then by id
|
||||
let mut order: Vec<usize> = (0..items.len()).collect();
|
||||
order.sort_by_key(|&i| (!touched.contains(&items[i].id), items[i].id));
|
||||
// pass 1: everybody who can keep their place keeps it
|
||||
let mut losers = Vec::new();
|
||||
for i in order {
|
||||
let orphan = items[i].container.is_some_and(|c| !ids.contains(&c) || !top.contains(&c));
|
||||
if orphan {
|
||||
items[i].container = None;
|
||||
}
|
||||
if orphan || taken.contains(&(items[i].container, items[i].slot)) {
|
||||
losers.push(i);
|
||||
} else {
|
||||
taken.insert((items[i].container, items[i].slot));
|
||||
}
|
||||
}
|
||||
// pass 2: the others move to a free slot (only now are all the kept places known)
|
||||
for i in losers {
|
||||
let slot = free_place(&taken).ok_or_else(|| PortableError::LimitExceeded("no free slot left for an item that lost its place".into()))?;
|
||||
items[i].container = None;
|
||||
items[i].slot = slot;
|
||||
taken.insert((None, slot));
|
||||
relocated.push(items[i].id);
|
||||
}
|
||||
Ok(relocated)
|
||||
}
|
||||
|
||||
fn merge_pets(cx: &mut Ctx, out: &mut PortableCharacter, target: &PortableCharacter, base: &PortableCharacter, ours: &PortableCharacter) -> PetOutcome {
|
||||
let by_id = |m: &PortableCharacter| -> HashMap<PortablePetId, PortablePet> { m.pets.iter().map(|p| (p.id, p.clone())).collect() };
|
||||
let (t, b, o) = (by_id(target), by_id(base), by_id(ours));
|
||||
let mut result = t.clone();
|
||||
let mut outcome = PetOutcome::default();
|
||||
for (id, tpet) in &t {
|
||||
match (b.get(id), o.get(id)) {
|
||||
(Some(bpet), Some(opet)) => {
|
||||
let merged = merge_pet(cx, tpet, bpet, opet, &id.to_string());
|
||||
if &merged != tpet {
|
||||
outcome.changed.push(*id);
|
||||
cx.changes.push(format!("pet {id}: changed ({})", tpet.name));
|
||||
}
|
||||
result.insert(*id, merged);
|
||||
}
|
||||
(Some(_), None) => {
|
||||
outcome.removed.push(*id);
|
||||
cx.changes.push(format!("pet {id}: removed ({})", tpet.name));
|
||||
result.remove(id);
|
||||
}
|
||||
(None, Some(opet)) => {
|
||||
result.insert(*id, opet.clone());
|
||||
outcome.reappeared.push(*id);
|
||||
cx.changes.push(format!("pet {id}: reappeared ({})", tpet.name));
|
||||
}
|
||||
(None, None) => outcome.filtered_kept.push(*id),
|
||||
}
|
||||
}
|
||||
for (id, opet) in &o {
|
||||
if t.contains_key(id) || b.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
result.insert(*id, opet.clone());
|
||||
outcome.added.push(*id);
|
||||
cx.changes.push(format!("pet {id}: new ({})", opet.name));
|
||||
}
|
||||
outcome.realm_local = b.keys().filter(|id| !t.contains_key(*id)).copied().collect();
|
||||
out.pets = result.into_values().collect();
|
||||
for v in [&mut outcome.added, &mut outcome.removed, &mut outcome.changed, &mut outcome.filtered_kept, &mut outcome.reappeared, &mut outcome.realm_local] {
|
||||
v.sort();
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
fn merge_pet(cx: &mut Ctx, target: &PortablePet, base: &PortablePet, ours: &PortablePet, path: &str) -> PortablePet {
|
||||
let mut out = target.clone();
|
||||
macro_rules! field {
|
||||
($name:ident) => {
|
||||
cx.scalar(&format!("pet {path}.{}", stringify!($name)), &mut out.$name, &base.$name, &ours.$name);
|
||||
};
|
||||
}
|
||||
field!(model_id);
|
||||
field!(created_by_spell);
|
||||
field!(pet_type);
|
||||
field!(level);
|
||||
field!(exp);
|
||||
field!(react_state);
|
||||
field!(name);
|
||||
field!(renamed);
|
||||
field!(slot);
|
||||
field!(health);
|
||||
field!(mana);
|
||||
field!(happiness);
|
||||
field!(action_bar);
|
||||
field!(declined_names);
|
||||
let mut spells = to_map(&out.spells, |s| (s.spell, s.active));
|
||||
cx.map(&format!("pet {path}.spells"), &mut spells, &to_map(&base.spells, |s| (s.spell, s.active)), &to_map(&ours.spells, |s| (s.spell, s.active)));
|
||||
out.spells = spells.into_iter().map(|(spell, active)| PetSpell { spell, active }).collect();
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,428 @@
|
||||
use super::*;
|
||||
use crate::portable::fixtures::{geared_level_eighty, id7};
|
||||
use crate::portable::ids::ContentId;
|
||||
|
||||
fn c0() -> PortableCharacter {
|
||||
geared_level_eighty()
|
||||
}
|
||||
|
||||
fn new_item(n: u64, slot: u8) -> PortableItem {
|
||||
PortableItem {
|
||||
id: PortableItemId::from_uuid(id7(900_000 + n)).unwrap(),
|
||||
container: None,
|
||||
slot,
|
||||
entry: ContentId::new("coa", "item", 77_000 + n).unwrap(),
|
||||
count: 1,
|
||||
duration: 0,
|
||||
charges: vec![],
|
||||
flags: 0,
|
||||
enchantments: vec![],
|
||||
random_property_id: 0,
|
||||
durability: 50,
|
||||
played_time: 0,
|
||||
text: None,
|
||||
creator_name: None,
|
||||
gift: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ids of the equipped / backpack items of the fixture that are not bags and hold nothing: safe to filter away.
|
||||
fn plain_items(m: &PortableCharacter) -> Vec<PortableItemId> {
|
||||
let containers: HashSet<PortableItemId> = m.items.iter().filter_map(|i| i.container).collect();
|
||||
m.items.iter().filter(|i| !containers.contains(&i.id) && i.container.is_none() && i.slot < 40).map(|i| i.id).collect()
|
||||
}
|
||||
|
||||
/// What a realm does to a freshly imported character by itself, before the player has done anything.
|
||||
fn normalized_by_the_realm(c0: &PortableCharacter) -> (PortableCharacter, Vec<PortableItemId>) {
|
||||
let mut b = c0.clone();
|
||||
// adds default spells and skills, removes spells its DBC does not know
|
||||
b.build.spells.extend([(6654, 255), (23250, 255)]);
|
||||
b.build.spells.retain(|(s, _)| s % 9 != 0);
|
||||
b.build.skills.push(Skill { skill: 9999, value: 1, max: 1 });
|
||||
// resets what it resets
|
||||
b.progression.honor.today_honor = 0;
|
||||
b.progression.honor.yesterday_honor = 0;
|
||||
b.progression.honor.today_kills = 0;
|
||||
b.progression.chosen_title = 0;
|
||||
b.progression.known_currencies |= 1 << 40;
|
||||
b.progression.taxi_mask = "827019776 269484704 ".into();
|
||||
b.progression.explored_zones = format!("{} 16384 ", b.progression.explored_zones.trim());
|
||||
b.reputation.push(ReputationEntry { faction: 9001, standing: 0, flags: 64 });
|
||||
b.settings.insert("core.ascension_slot.0".into(), vec![1, 21, 0, 0, 0]);
|
||||
// mails away what it cannot place
|
||||
let filtered: Vec<PortableItemId> = plain_items(c0).into_iter().take(7).collect();
|
||||
b.items.retain(|i| !filtered.contains(&i.id));
|
||||
// and gives the character things of its own
|
||||
b.items.push(new_item(1, 30));
|
||||
b.items.push(new_item(2, 31));
|
||||
// the realm also normalises the pet
|
||||
(b.normalized(), filtered)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalisation_alone_changes_nothing_in_the_canonical_character() {
|
||||
let c0 = c0();
|
||||
let (b0, filtered) = normalized_by_the_realm(&c0);
|
||||
assert_ne!(b0, c0, "the realm really changed things");
|
||||
let merged = merge3(&c0, &b0, &b0, Mode::Lenient).unwrap();
|
||||
assert_eq!(merged.model, c0, "no session progress: the canonical character is exactly as it was");
|
||||
assert!(merged.changes.is_empty(), "{:?}", merged.changes);
|
||||
assert!(!merged.left_alone.is_empty(), "the realm's own changes are reported as left alone");
|
||||
assert_eq!(merged.items.filtered_kept.len(), filtered.len());
|
||||
assert_eq!(merged.items.realm_local.len(), 2);
|
||||
assert!(merged.items.removed.is_empty() && merged.items.added.is_empty());
|
||||
assert!(merged.conflicts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_session_delta_is_applied() {
|
||||
let c0 = c0();
|
||||
let (b0, filtered) = normalized_by_the_realm(&c0);
|
||||
let mut b1 = b0.clone();
|
||||
|
||||
// the player plays
|
||||
b1.progression.money += 12_345;
|
||||
b1.progression.xp += 50;
|
||||
b1.progression.honor.total_honor += 100;
|
||||
b1.progression.honor.today_honor = 7;
|
||||
b1.build.spells.push((133, 255));
|
||||
let unlearned = c0.build.spells.iter().map(|s| s.0).find(|s| s % 9 != 0).unwrap();
|
||||
b1.build.spells.retain(|(s, _)| *s != unlearned);
|
||||
b1.quests.rewarded.push(12_010);
|
||||
let finished = b1.quests.active.remove(0);
|
||||
b1.reputation.iter_mut().find(|r| r.faction == 3).unwrap().standing += 500;
|
||||
b1.settings.insert("core.ascension_build.54".into(), vec![3, 1, 2, 3]);
|
||||
b1.pets[0].level = 81;
|
||||
b1.pets[0].exp += 500;
|
||||
b1.pets[0].spells.push(PetSpell { spell: 777, active: 1 });
|
||||
// items: one sold, one with a changed stack, one moved, one new
|
||||
let sold = plain_items(&c0).into_iter().find(|id| !filtered.contains(id)).unwrap();
|
||||
b1.items.retain(|i| i.id != sold);
|
||||
let restacked = b1.items.iter().position(|i| i.container.is_some()).unwrap();
|
||||
b1.items[restacked].count += 4;
|
||||
let restacked_id = b1.items[restacked].id;
|
||||
let loot = new_item(50, 33);
|
||||
b1.items.push(loot.clone());
|
||||
let b1 = b1.normalized();
|
||||
|
||||
let m = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap();
|
||||
let r = &m.model;
|
||||
// progress is in ...
|
||||
assert_eq!(r.progression.money, c0.progression.money + 12_345);
|
||||
assert_eq!(r.progression.xp, c0.progression.xp + 50);
|
||||
assert_eq!(r.progression.honor.total_honor, c0.progression.honor.total_honor + 100);
|
||||
assert_eq!(r.progression.honor.today_honor, 7);
|
||||
assert!(r.build.spells.contains(&(133, 255)));
|
||||
assert!(!r.build.spells.iter().any(|(s, _)| *s == unlearned), "what the player unlearned is gone");
|
||||
assert!(r.quests.rewarded.contains(&12_010));
|
||||
assert!(!r.quests.active.iter().any(|q| q.quest == finished.quest));
|
||||
assert_eq!(r.reputation.iter().find(|x| x.faction == 3).unwrap().standing, c0.reputation.iter().find(|x| x.faction == 3).unwrap().standing + 500);
|
||||
assert_eq!(r.settings["core.ascension_build.54"], vec![3, 1, 2, 3]);
|
||||
assert_eq!((r.pets[0].level, r.pets[0].exp), (81, c0.pets[0].exp + 500));
|
||||
assert!(r.pets[0].spells.iter().any(|s| s.spell == 777));
|
||||
assert!(!r.items.iter().any(|i| i.id == sold), "the sold item is gone");
|
||||
assert_eq!(r.items.iter().find(|i| i.id == restacked_id).unwrap().count, c0.items.iter().find(|i| i.id == restacked_id).unwrap().count + 4);
|
||||
assert!(r.items.contains(&loot), "the new item is in");
|
||||
// ... and none of the realm's own normalisation is
|
||||
assert!(!r.build.spells.iter().any(|(s, _)| *s == 6654 || *s == 23250), "default spells stay out");
|
||||
assert!(c0.build.spells.iter().filter(|(s, _)| s % 9 == 0).all(|sp| r.build.spells.contains(sp)), "spells its DBC dropped stay canonical");
|
||||
assert_eq!(r.progression.chosen_title, c0.progression.chosen_title, "a title reset by the realm is not the player's doing");
|
||||
assert_eq!(r.progression.known_currencies, c0.progression.known_currencies);
|
||||
assert_eq!(r.progression.taxi_mask, c0.progression.taxi_mask);
|
||||
assert!(!r.reputation.iter().any(|x| x.faction == 9001));
|
||||
assert!(!r.settings.contains_key("core.ascension_slot.0"));
|
||||
assert!(!r.build.skills.iter().any(|s| s.skill == 9999));
|
||||
for id in &filtered {
|
||||
assert!(r.items.iter().any(|i| i.id == *id), "an item the realm filtered away stays canonical");
|
||||
}
|
||||
assert!(!r.items.iter().any(|i| i.entry.id() == 77_001 || i.entry.id() == 77_002), "the realm's own items are not merged");
|
||||
|
||||
assert_eq!(m.items.removed, vec![sold]);
|
||||
assert_eq!(m.items.added, vec![loot.id]);
|
||||
assert_eq!(m.items.changed, vec![restacked_id]);
|
||||
assert_eq!(m.items.filtered_kept.len(), filtered.len());
|
||||
r.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_merge_is_repeatable() {
|
||||
let c0 = c0();
|
||||
let (b0, _) = normalized_by_the_realm(&c0);
|
||||
let mut b1 = b0.clone();
|
||||
b1.progression.money += 99;
|
||||
b1.items.push(new_item(51, 34));
|
||||
let b1 = b1.normalized();
|
||||
let first = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap().model;
|
||||
let second = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap().model;
|
||||
assert_eq!(first, second, "a checkpoint can be recomputed from the same baseline any number of times");
|
||||
// and a later checkpoint is computed from the same C0, not stacked on the earlier result
|
||||
let mut b2 = b1.clone();
|
||||
b2.progression.money += 1;
|
||||
let third = merge3(&c0, &b0, &b2, Mode::Lenient).unwrap().model;
|
||||
assert_eq!(third.progression.money, c0.progression.money + 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn money_is_a_delta_so_a_realm_that_started_elsewhere_does_not_overwrite_it() {
|
||||
let c0 = c0();
|
||||
let mut b0 = c0.clone();
|
||||
b0.progression.money = c0.progression.money - 1_000; // the realm normalised money a little (e.g. a deposit)
|
||||
let mut b1 = b0.clone();
|
||||
b1.progression.money += 250;
|
||||
let m = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap().model;
|
||||
assert_eq!(m.progression.money, c0.progression.money + 250);
|
||||
// clamped at the realm's limits
|
||||
let mut huge = b0.clone();
|
||||
huge.progression.money = limits::MAX_MONEY;
|
||||
assert_eq!(merge3(&c0, &b0, &huge, Mode::Lenient).unwrap().model.progression.money, limits::MAX_MONEY);
|
||||
let mut broke = b0.clone();
|
||||
broke.progression.money = 0;
|
||||
assert_eq!(merge3(&c0, &b0, &broke, Mode::Lenient).unwrap().model.progression.money, 1_000, "the realm lost all it had: the canonical character loses the same amount");
|
||||
let (mut poor, mut poor_b0) = (c0.clone(), c0.clone());
|
||||
(poor.progression.money, poor_b0.progression.money) = (10, 100);
|
||||
let mut poor_b1 = poor_b0.clone();
|
||||
poor_b1.progression.money = 0;
|
||||
assert_eq!(merge3(&poor, &poor_b0, &poor_b1, Mode::Lenient).unwrap().model.progression.money, 0, "never below zero");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_level_up_takes_the_realms_level_and_xp_together() {
|
||||
let c0 = c0();
|
||||
let b0 = c0.clone();
|
||||
let mut b1 = b0.clone();
|
||||
b1.progression.level = 81;
|
||||
b1.progression.xp = 17;
|
||||
let m = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap().model;
|
||||
assert_eq!((m.progression.level, m.progression.xp), (81, 17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bit_sets_only_gain_what_the_session_gained() {
|
||||
let mut c0 = c0();
|
||||
c0.progression.explored_zones = "3 0 8 ".into();
|
||||
c0.progression.known_titles = "1 0 ".into();
|
||||
let mut b0 = c0.clone();
|
||||
b0.progression.explored_zones = "3 64 ".into(); // the realm added a bit of its own and dropped bit 3 of word 2
|
||||
let mut b1 = b0.clone();
|
||||
b1.progression.explored_zones = "7 64 ".into(); // the player explored bit 2 of word 0
|
||||
b1.progression.known_titles = "3 0 ".into();
|
||||
let m = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap().model;
|
||||
assert_eq!(m.progression.explored_zones.trim(), "7 0 8", "gained bit in, the realm's own bit out, the canonical bit the realm lacked stays");
|
||||
assert_eq!(m.progression.known_titles.trim(), "3 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_the_realm_filtered_away_stay_while_items_the_player_got_rid_of_go() {
|
||||
let c0 = c0();
|
||||
let (b0, filtered) = normalized_by_the_realm(&c0);
|
||||
// session: the player deletes one present item; nothing else
|
||||
let gone = plain_items(&c0).into_iter().find(|id| !filtered.contains(id)).unwrap();
|
||||
let mut b1 = b0.clone();
|
||||
b1.items.retain(|i| i.id != gone);
|
||||
let m = merge3(&c0, &b0, &b1.normalized(), Mode::Lenient).unwrap();
|
||||
assert_eq!(m.items.removed, vec![gone]);
|
||||
assert_eq!(m.items.filtered_kept.len(), 7);
|
||||
for id in &filtered {
|
||||
assert!(m.model.items.iter().any(|i| i.id == *id));
|
||||
}
|
||||
assert!(!m.model.items.iter().any(|i| i.id == gone));
|
||||
assert_eq!(m.model.items.len(), c0.items.len() - 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filtered_item_that_comes_back_is_taken_from_the_realm_but_keeps_its_crafter() {
|
||||
let c0 = c0();
|
||||
let (b0, filtered) = normalized_by_the_realm(&c0);
|
||||
let back = filtered[0];
|
||||
let mut returned = c0.items.iter().find(|i| i.id == back).unwrap().clone();
|
||||
returned.count = 3;
|
||||
returned.slot = 36;
|
||||
returned.creator_name = None; // the realm never knew the crafter
|
||||
let mut b1 = b0.clone();
|
||||
b1.items.push(returned);
|
||||
let m = merge3(&c0, &b0, &b1.normalized(), Mode::Lenient).unwrap();
|
||||
assert_eq!(m.items.reappeared, vec![back]);
|
||||
let item = m.model.items.iter().find(|i| i.id == back).unwrap();
|
||||
assert_eq!((item.count, item.slot), (3, 36));
|
||||
assert_eq!(item.creator_name, c0.items.iter().find(|i| i.id == back).unwrap().creator_name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_place_taken_by_the_session_moves_the_item_that_was_filtered_there() {
|
||||
let c0 = c0();
|
||||
let (b0, filtered) = normalized_by_the_realm(&c0);
|
||||
let victim = c0.items.iter().find(|i| i.id == filtered[0]).unwrap().clone();
|
||||
// the player puts a new item exactly where the filtered one canonically is
|
||||
let mut occupant = new_item(60, victim.slot);
|
||||
occupant.container = victim.container;
|
||||
let mut b1 = b0.clone();
|
||||
b1.items.push(occupant.clone());
|
||||
let m = merge3(&c0, &b0, &b1.normalized(), Mode::Lenient).unwrap();
|
||||
m.model.validate().unwrap();
|
||||
let moved = m.model.items.iter().find(|i| i.id == victim.id).unwrap();
|
||||
assert_ne!((moved.container, moved.slot), (victim.container, victim.slot));
|
||||
assert!(moved.container.is_none() && (23..=66).contains(&moved.slot));
|
||||
assert_eq!(m.items.relocated, vec![victim.id]);
|
||||
let kept = m.model.items.iter().find(|i| i.id == occupant.id).unwrap();
|
||||
assert_eq!((kept.container, kept.slot), (occupant.container, occupant.slot), "the session's item keeps its place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pets_are_matched_by_their_portable_id() {
|
||||
let c0 = c0();
|
||||
let b0 = c0.clone();
|
||||
let mut b1 = b0.clone();
|
||||
let mut second = b1.pets[0].clone();
|
||||
second.id = PortablePetId::new();
|
||||
second.name = "Newcomer".into();
|
||||
second.slot = 1;
|
||||
b1.pets.push(second.clone());
|
||||
b1.pets[0].name = "Renamed".into();
|
||||
let m = merge3(&c0, &b0, &b1.normalized(), Mode::Lenient).unwrap();
|
||||
assert_eq!(m.pets.added, vec![second.id]);
|
||||
assert_eq!(m.pets.changed, vec![c0.pets[0].id]);
|
||||
assert!(m.model.pets.iter().any(|p| p.name == "Renamed") && m.model.pets.iter().any(|p| p.name == "Newcomer"));
|
||||
// abandoned pet
|
||||
let mut b2 = b0.clone();
|
||||
b2.pets.clear();
|
||||
let m = merge3(&c0, &b0, &b2, Mode::Lenient).unwrap();
|
||||
assert_eq!(m.pets.removed, vec![c0.pets[0].id]);
|
||||
assert!(m.model.pets.is_empty());
|
||||
// a pet the realm filtered away is kept
|
||||
let mut b0f = c0.clone();
|
||||
b0f.pets.clear();
|
||||
let m = merge3(&c0, &b0f, &b0f, Mode::Lenient).unwrap();
|
||||
assert_eq!(m.pets.filtered_kept, vec![c0.pets[0].id]);
|
||||
assert_eq!(m.model.pets.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarantined_extensions_and_stock_talents_are_never_touched_by_a_realm() {
|
||||
let mut c0 = c0();
|
||||
c0.extensions.insert("coa:unlisted-settings".into(), Extension::new("2", 1, b"{}".to_vec()));
|
||||
c0.build.talents = vec![(900_001, 1)];
|
||||
let b0 = c0.clone();
|
||||
let mut b1 = b0.clone();
|
||||
b1.extensions.clear();
|
||||
b1.build.talents.clear();
|
||||
let m = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap().model;
|
||||
assert!(m.extensions.contains_key("coa:unlisted-settings"));
|
||||
assert_eq!(m.build.talents, vec![(900_001, 1)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_never_changes_who_the_character_is() {
|
||||
let c0 = c0();
|
||||
let (b0, _) = normalized_by_the_realm(&c0);
|
||||
let m = merge3(&c0, &b0, &b0, Mode::Lenient).unwrap().model;
|
||||
assert_eq!((m.character_id, m.ruleset, m.content_namespace.as_str()), (c0.character_id, c0.ruleset, c0.content_namespace.as_str()));
|
||||
let mut wild = b0.clone();
|
||||
wild.ruleset = Ruleset::Wildcard;
|
||||
assert!(merge3(&c0, &b0, &wild, Mode::Lenient).is_err(), "CoA and Wildcard never mix");
|
||||
}
|
||||
|
||||
// ---- the other direction: canonical -> realm, strict ------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn strict_mode_reports_only_real_conflicts() {
|
||||
let s = c0(); // what the realm was last synced with
|
||||
let mut canonical = s.clone();
|
||||
canonical.progression.money += 500;
|
||||
canonical.progression.chosen_title = 9;
|
||||
canonical.build.spells.push((133, 255));
|
||||
let canonical = canonical.normalized();
|
||||
|
||||
// the realm drifted on its own in other places: no conflict, its drift is kept
|
||||
let mut realm = s.clone();
|
||||
realm.progression.honor.today_honor = 0;
|
||||
realm.build.spells.push((6654, 255));
|
||||
realm.progression.money += 7; // an accumulator: both deltas add
|
||||
let m = merge3(&realm.clone().normalized(), &s, &canonical, Mode::Strict).unwrap();
|
||||
assert!(m.conflicts.is_empty(), "{:?}", m.conflicts);
|
||||
assert_eq!(m.model.progression.money, s.progression.money + 7 + 500);
|
||||
assert_eq!(m.model.progression.chosen_title, 9);
|
||||
assert!(m.model.build.spells.contains(&(6654, 255)) && m.model.build.spells.contains(&(133, 255)));
|
||||
assert_eq!(m.model.progression.honor.today_honor, 0, "the realm's own value is kept");
|
||||
|
||||
// both sides moved the same title elsewhere: a conflict, and nothing is applied for it
|
||||
let mut realm = s.clone();
|
||||
realm.progression.chosen_title = 4;
|
||||
let m = merge3(&realm.normalized(), &s, &canonical, Mode::Strict).unwrap();
|
||||
assert_eq!(m.conflicts.len(), 1, "{:?}", m.conflicts);
|
||||
assert_eq!(m.conflicts[0].path, "progression.chosen_title");
|
||||
assert_eq!(m.model.progression.chosen_title, 4);
|
||||
|
||||
// both sides ended on the same value: no conflict
|
||||
let mut realm = s.clone();
|
||||
realm.progression.chosen_title = 9;
|
||||
assert!(merge3(&realm.normalized(), &s, &canonical, Mode::Strict).unwrap().conflicts.is_empty());
|
||||
|
||||
// canonical removed a spell the realm changed
|
||||
let mut canonical2 = s.clone();
|
||||
let victim = s.build.spells[3].0;
|
||||
canonical2.build.spells.retain(|(x, _)| *x != victim);
|
||||
let mut realm = s.clone();
|
||||
realm.build.spells.iter_mut().find(|(x, _)| *x == victim).unwrap().1 = 255;
|
||||
let m = merge3(&realm.normalized(), &s, &canonical2.normalized(), Mode::Strict).unwrap();
|
||||
assert_eq!(m.conflicts.len(), 1, "{:?}", m.conflicts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn items_filtered_by_the_target_are_not_resurrected_by_an_update() {
|
||||
let s = c0();
|
||||
let filtered_at_realm = plain_items(&s)[0];
|
||||
let mut realm = s.clone();
|
||||
realm.items.retain(|i| i.id != filtered_at_realm); // this realm never had it
|
||||
let mut canonical = s.clone();
|
||||
canonical.items.iter_mut().find(|i| i.id == filtered_at_realm).unwrap().count = 9; // the player changed it elsewhere
|
||||
canonical.items.push(new_item(70, 35));
|
||||
let m = merge3(&realm.normalized(), &s, &canonical.normalized(), Mode::Strict).unwrap();
|
||||
assert!(!m.model.items.iter().any(|i| i.id == filtered_at_realm), "an update does not put a filtered item into a realm");
|
||||
assert_eq!(m.items.added.len(), 1);
|
||||
assert_eq!(m.items.realm_local, vec![filtered_at_realm]);
|
||||
}
|
||||
|
||||
// ---- properties -------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// Whatever the realm does by itself, a session without progress changes nothing.
|
||||
#[test]
|
||||
fn random_realm_normalisation_never_leaks_into_the_canonical_character() {
|
||||
let mut x = 0x9E37_79B9u32;
|
||||
let mut next = move || {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
x
|
||||
};
|
||||
for round in 0..300 {
|
||||
let c0 = c0();
|
||||
let mut b0 = c0.clone();
|
||||
for _ in 0..1 + next() % 6 {
|
||||
match next() % 9 {
|
||||
0 => b0.build.spells.push((10_000 + next() % 5_000, 255)),
|
||||
1 => {
|
||||
let n = next() as usize % b0.build.spells.len();
|
||||
b0.build.spells.remove(n);
|
||||
}
|
||||
2 => b0.progression.honor.today_honor = next() % 50,
|
||||
3 => b0.progression.known_currencies ^= 1 << (next() % 60),
|
||||
4 => b0.reputation.push(ReputationEntry { faction: 20_000 + next() % 100, standing: 0, flags: 1 }),
|
||||
5 => {
|
||||
let n = next() as usize % b0.items.len();
|
||||
if b0.items[n].container.is_none() && b0.items.iter().all(|i| i.container != Some(b0.items[n].id)) {
|
||||
b0.items.remove(n);
|
||||
}
|
||||
}
|
||||
6 => b0.items.push(new_item(1_000 + round, 40 + (next() % 20) as u8)),
|
||||
7 => {
|
||||
b0.settings.insert(format!("core.ascension_slot.{}", next() % 5), vec![next() % 9]);
|
||||
}
|
||||
_ => b0.progression.taxi_mask = format!("{} ", next()),
|
||||
}
|
||||
}
|
||||
let b0 = b0.normalized();
|
||||
let m = merge3(&c0, &b0, &b0, Mode::Lenient).unwrap();
|
||||
assert_eq!(m.model, c0, "round {round}");
|
||||
assert!(m.changes.is_empty(), "round {round}: {:?}", m.changes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
-- Schema 4: what Phase 4 (reconciliation) needs to remember between sessions.
|
||||
--
|
||||
-- * item/pet mappings know whether the realm *holds* the item ("present"), has filtered it away (mailed, unknown entry,
|
||||
-- cannot equip: "filtered", the canonical character still owns it) or whether it is the realm's own addition
|
||||
-- ("realm_local", never merged into the canonical character);
|
||||
-- * pets get stable per-realm mappings like items;
|
||||
-- * `synced_*` keeps the canonical snapshot a realm was last synchronised with (the base of the next in-place update);
|
||||
-- * `realm_baseline` keeps C0 (canonical at join) and B0 (the realm after its own first load/save normalisation, before
|
||||
-- any progression): the base of the three-way reconciliation, persisted so it survives restarts and history pruning;
|
||||
-- * the import journal also journals in-place updates of an existing realm character.
|
||||
|
||||
ALTER TABLE item_mapping ADD COLUMN presence TEXT NOT NULL DEFAULT 'present' CHECK (presence IN ('present', 'filtered', 'realm_local'));
|
||||
|
||||
CREATE TABLE pet_mapping (
|
||||
mapping_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
character_id TEXT NOT NULL,
|
||||
server_id TEXT NOT NULL,
|
||||
portable_pet_id TEXT NOT NULL,
|
||||
local_pet_number INTEGER NOT NULL,
|
||||
identity TEXT NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('active', 'retired')),
|
||||
presence TEXT NOT NULL DEFAULT 'present' CHECK (presence IN ('present', 'filtered', 'realm_local')),
|
||||
created_revision INTEGER NOT NULL,
|
||||
confirmed_revision INTEGER NOT NULL,
|
||||
retired_revision INTEGER,
|
||||
retired_reason TEXT CHECK (retired_reason IS NULL OR retired_reason IN ('guid_reused', 'moved', 'absent', 'character_rebound')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
CHECK ((state = 'active') = (retired_revision IS NULL)),
|
||||
FOREIGN KEY (character_id, server_id) REFERENCES character_server_mapping(character_id, server_id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
CREATE UNIQUE INDEX pet_mapping_active_pet ON pet_mapping(character_id, server_id, portable_pet_id) WHERE state = 'active';
|
||||
CREATE UNIQUE INDEX pet_mapping_active_number ON pet_mapping(character_id, server_id, local_pet_number) WHERE state = 'active';
|
||||
|
||||
ALTER TABLE character_server_mapping ADD COLUMN synced_hash BLOB;
|
||||
ALTER TABLE character_server_mapping ADD COLUMN synced_payload BLOB;
|
||||
|
||||
CREATE TABLE realm_baseline (
|
||||
character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE,
|
||||
server_id TEXT NOT NULL,
|
||||
c0_revision INTEGER NOT NULL,
|
||||
-- the canonical revision this session has produced so far (starts at c0_revision); the next reconcile must still find it
|
||||
head_revision INTEGER NOT NULL,
|
||||
state TEXT NOT NULL CHECK (state IN ('open', 'closed')),
|
||||
c0_hash BLOB NOT NULL CHECK (length(c0_hash) = 32),
|
||||
c0_payload BLOB NOT NULL,
|
||||
b0_hash BLOB NOT NULL CHECK (length(b0_hash) = 32),
|
||||
b0_payload BLOB NOT NULL,
|
||||
note TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (character_id, server_id, c0_revision)
|
||||
) STRICT;
|
||||
CREATE UNIQUE INDEX baseline_one_open ON realm_baseline(character_id, server_id) WHERE state = 'open';
|
||||
|
||||
ALTER TABLE import_journal ADD COLUMN kind TEXT NOT NULL DEFAULT 'import' CHECK (kind IN ('import', 'update'));
|
||||
-- ids of items / pets the update retires from the realm's mappings
|
||||
ALTER TABLE import_journal ADD COLUMN retired_items TEXT NOT NULL DEFAULT '[]';
|
||||
ALTER TABLE import_journal ADD COLUMN retired_pets TEXT NOT NULL DEFAULT '[]';
|
||||
-- the canonical snapshot the realm is being synchronised with (zstd canonical JSON)
|
||||
ALTER TABLE import_journal ADD COLUMN target_hash BLOB;
|
||||
ALTER TABLE import_journal ADD COLUMN target_payload BLOB;
|
||||
@@ -10,6 +10,7 @@ pub mod collection;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod ids;
|
||||
pub mod merge;
|
||||
pub mod model;
|
||||
pub mod realm;
|
||||
pub mod snapshot;
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::super::error::{PortableError, Result};
|
||||
use super::super::identity::item_identity;
|
||||
use super::super::ids::{CharacterId, ContentId, PortableItemId, PortablePetId};
|
||||
use super::super::model::*;
|
||||
use super::super::store::ItemObservation;
|
||||
use super::super::store::{pet_identity, ItemObservation, PetObservation};
|
||||
use super::super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
|
||||
use super::blockers::{is_internal_account, Blocker};
|
||||
use super::policy::{classify_setting, Disposition, QUARANTINE_EXTENSION, SETTINGS_POLICY_VERSION};
|
||||
@@ -29,6 +29,8 @@ pub struct ExportRequest<'a> {
|
||||
pub character_id: Option<CharacterId>,
|
||||
/// Active item mappings of that character on this realm: local item guid -> (portable item id, identity).
|
||||
pub prior_items: &'a HashMap<u32, (PortableItemId, String)>,
|
||||
/// Active pet mappings of that character on this realm: local pet number -> (portable pet id, identity).
|
||||
pub prior_pets: &'a HashMap<u32, (PortablePetId, String)>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -39,6 +41,8 @@ pub struct Exported {
|
||||
pub account: u32,
|
||||
/// One entry per exported item, ready for `Store::reconcile_item_mappings`.
|
||||
pub observations: Vec<ItemObservation>,
|
||||
/// One entry per exported pet, ready for the pet mappings.
|
||||
pub pet_observations: Vec<PetObservation>,
|
||||
/// Things that were left out or looked wrong; shown to the user, never silently dropped.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
@@ -182,7 +186,7 @@ pub fn build(raw: &RawExport, req: &ExportRequest<'_>) -> Result<Exported> {
|
||||
.map(|r| Ok(ActionButton { spec: r.u8("spec")?, button: r.u8("button")?, action: r.u32("action")?, kind: r.u8("type")? }))
|
||||
.collect::<Result<_>>()?;
|
||||
|
||||
let pets = pets(raw, ns)?;
|
||||
let (pets, pet_observations) = pets(raw, ns, req.prior_pets)?;
|
||||
let (settings, quarantined) = settings(raw, req.ruleset)?;
|
||||
|
||||
let mut client_data = BTreeMap::new();
|
||||
@@ -218,7 +222,7 @@ pub fn build(raw: &RawExport, req: &ExportRequest<'_>) -> Result<Exported> {
|
||||
.normalized();
|
||||
model.validate()?;
|
||||
|
||||
Ok(Exported { model, local_guid: req.local_guid, account: c.u32("account")?, observations, warnings })
|
||||
Ok(Exported { model, local_guid: req.local_guid, account: c.u32("account")?, observations, pet_observations, warnings })
|
||||
}
|
||||
|
||||
fn pairs(raw: &RawExport, section: &str) -> Result<Vec<(u32, u8)>> {
|
||||
@@ -336,7 +340,7 @@ fn items(raw: &RawExport, req: &ExportRequest<'_>, ns: &str, warnings: &mut Vec<
|
||||
Ok((items, observations))
|
||||
}
|
||||
|
||||
fn pets(raw: &RawExport, ns: &str) -> Result<Vec<PortablePet>> {
|
||||
fn pets(raw: &RawExport, ns: &str, prior: &HashMap<u32, (PortablePetId, String)>) -> Result<(Vec<PortablePet>, Vec<PetObservation>)> {
|
||||
let mut spells: HashMap<u32, Vec<PetSpell>> = HashMap::new();
|
||||
for r in raw.section("pet_spells")?.iter() {
|
||||
spells.entry(r.u32("pet")?).or_default().push(PetSpell { spell: r.u32("spell")?, active: r.u8("active")? });
|
||||
@@ -345,31 +349,40 @@ fn pets(raw: &RawExport, ns: &str) -> Result<Vec<PortablePet>> {
|
||||
for r in raw.section("pet_declined")?.iter() {
|
||||
declined.insert(r.u32("id")?, [r.text("n1")?, r.text("n2")?, r.text("n3")?, r.text("n4")?, r.text("n5")?]);
|
||||
}
|
||||
raw.section("pets")?
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let number = r.u32("id")?;
|
||||
Ok(PortablePet {
|
||||
id: PortablePetId::new(),
|
||||
entry: ContentId::new(ns, "creature", r.u64("entry")?)?,
|
||||
model_id: r.u32("model")?,
|
||||
created_by_spell: r.u32("created_by")?,
|
||||
pet_type: r.u8("pet_type")?,
|
||||
level: r.u16("level")?,
|
||||
exp: r.u32("exp")?,
|
||||
react_state: r.u8("react")?,
|
||||
name: r.text("name")?,
|
||||
renamed: r.u64("renamed")? != 0,
|
||||
slot: r.u8("slot")?,
|
||||
health: r.u32("health")?,
|
||||
mana: r.u32("mana")?,
|
||||
happiness: r.u32("happiness")?,
|
||||
action_bar: r.text("abdata")?,
|
||||
spells: spells.remove(&number).unwrap_or_default(),
|
||||
declined_names: declined.remove(&number),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
let mut observations = Vec::new();
|
||||
let mut pets = Vec::new();
|
||||
for r in raw.section("pets")?.iter() {
|
||||
let number = r.u32("id")?;
|
||||
let entry = ContentId::new(ns, "creature", r.u64("entry")?)?;
|
||||
let (pet_type, created_by) = (r.u8("pet_type")?, r.u32("created_by")?);
|
||||
let identity = pet_identity(&entry, pet_type, created_by);
|
||||
// a pet keeps its portable id while the realm's number still names the same pet; a recycled number is a new pet
|
||||
let id = match prior.get(&number) {
|
||||
Some((id, prior_identity)) if *prior_identity == identity => *id,
|
||||
_ => PortablePetId::new(),
|
||||
};
|
||||
observations.push(PetObservation { portable_pet_id: id, local_pet_number: number, identity });
|
||||
pets.push(PortablePet {
|
||||
id,
|
||||
entry,
|
||||
model_id: r.u32("model")?,
|
||||
created_by_spell: created_by,
|
||||
pet_type,
|
||||
level: r.u16("level")?,
|
||||
exp: r.u32("exp")?,
|
||||
react_state: r.u8("react")?,
|
||||
name: r.text("name")?,
|
||||
renamed: r.u64("renamed")? != 0,
|
||||
slot: r.u8("slot")?,
|
||||
health: r.u32("health")?,
|
||||
mana: r.u32("mana")?,
|
||||
happiness: r.u32("happiness")?,
|
||||
action_bar: r.text("abdata")?,
|
||||
spells: spells.remove(&number).unwrap_or_default(),
|
||||
declined_names: declined.remove(&number),
|
||||
});
|
||||
}
|
||||
Ok((pets, observations))
|
||||
}
|
||||
|
||||
/// `(carried, quarantined)`.
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::db::Db;
|
||||
use super::super::error::{PortableError, Result};
|
||||
use super::super::ids::{CharacterId, ContentId, ImportId};
|
||||
use super::super::model::{PortableCharacter, Ruleset};
|
||||
use super::super::store::{ImportAllocation, ImportState, JournalEntry, PlannedItem, Store};
|
||||
use super::super::store::{pet_identity, ImportAllocation, ImportState, JournalEntry, JournalKind, PlannedItem, PlannedPet, Store};
|
||||
use super::plan::{build_plan, parse_report, Allocation, ImportPlan, PlanContext, IMPORT_LOCK};
|
||||
use super::script::{self, parse_output, Query};
|
||||
use super::sqlenc::Val;
|
||||
@@ -69,6 +69,8 @@ pub enum ImportProblem {
|
||||
MissingPetCreatures(Vec<u32>),
|
||||
/// An id of a namespace this importer does not apply (mod content, another ruleset).
|
||||
UnsupportedContent(String),
|
||||
/// An update names a realm character that does not exist or is deleted.
|
||||
CharacterUnavailable(u32),
|
||||
}
|
||||
|
||||
impl fmt::Display for ImportProblem {
|
||||
@@ -85,6 +87,7 @@ impl fmt::Display for ImportProblem {
|
||||
ImportProblem::MissingItems(v) => write!(f, "the realm does not know these item entries: {}", list(v)),
|
||||
ImportProblem::MissingPetCreatures(v) => write!(f, "the realm does not know these pet creatures: {}", list(v)),
|
||||
ImportProblem::UnsupportedContent(s) => write!(f, "{s}"),
|
||||
ImportProblem::CharacterUnavailable(g) => write!(f, "the realm has no (undeleted) character {g}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,6 +238,10 @@ pub(crate) fn planned_items(model: &PortableCharacter) -> Vec<PlannedItem> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn planned_pets(model: &PortableCharacter) -> Vec<PlannedPet> {
|
||||
model.pets.iter().map(|p| PlannedPet { id: p.id, entry: p.entry.clone(), identity: pet_identity(&p.entry, p.pet_type, p.created_by_spell) }).collect()
|
||||
}
|
||||
|
||||
/// Run the generated script in the realm. `Ok` carries the realm's report; any SQL error means the transaction rolled back
|
||||
/// or never committed (the caller still verifies through the marker, because a lost answer is indistinguishable).
|
||||
pub fn run_realm_import(db: &Db, plan: &ImportPlan) -> Result<(Allocation, bool)> {
|
||||
@@ -257,8 +264,8 @@ pub fn import_character(db: &Db, store: &mut Store, character_id: CharacterId, s
|
||||
let probe = super::probe(db)?;
|
||||
|
||||
let items = planned_items(&model);
|
||||
let pet_ids: Vec<_> = model.pets.iter().map(|p| p.id).collect();
|
||||
let ticket = store.begin_import(character_id, server_id, record.revision, &items, &pet_ids)?;
|
||||
let pets = planned_pets(&model);
|
||||
let ticket = store.begin_import(character_id, server_id, record.revision, &items, &pets)?;
|
||||
|
||||
let plan = match build_plan(&model, &PlanContext { ruleset: ruleset_of(db), account, revision: record.revision, nonce: ticket.nonce, max_characters_per_account: opts.max_characters_per_account, game_server_users: &opts.game_server_users, probe: &probe }) {
|
||||
Ok(plan) => plan,
|
||||
@@ -351,6 +358,9 @@ fn recovery_script(marker: &str, wait: u32) -> String {
|
||||
/// `with_grace`: refuse to call an import "never committed" while it is younger than the recovery grace period.
|
||||
pub fn resolve_import(db: &Db, store: &mut Store, import_id: ImportId, opts: &ImportOptions, with_grace: bool) -> Result<Resolution> {
|
||||
let entry: JournalEntry = store.import_entry(import_id)?;
|
||||
if entry.kind == JournalKind::Update {
|
||||
return super::reconcile::resolve_import_update(db, store, import_id, opts, with_grace);
|
||||
}
|
||||
match entry.state {
|
||||
ImportState::Committed => return Ok(Resolution::Committed(entry.allocation.expect("a committed entry has its allocation"))),
|
||||
ImportState::Aborted => return Ok(Resolution::Aborted),
|
||||
@@ -381,8 +391,8 @@ pub fn resolve_import(db: &Db, store: &mut Store, import_id: ImportId, opts: &Im
|
||||
let (n_items, min_item, max_item) = triple("items")?;
|
||||
let (n_pets, min_pet, max_pet) = triple("pets")?;
|
||||
let contiguous = |n: u64, min: u64, max: u64| n == 0 || max - min + 1 == n;
|
||||
if n_items as usize != entry.items.len() || n_pets as usize != entry.pet_ids.len() || !contiguous(n_items, min_item, max_item) || !contiguous(n_pets, min_pet, max_pet) {
|
||||
let detail = format!("the realm has a character with this import's marker (guid {local_guid}) but {n_items} items / {n_pets} pets where the plan had {} / {}", entry.items.len(), entry.pet_ids.len());
|
||||
if n_items as usize != entry.items.len() || n_pets as usize != entry.pets.len() || !contiguous(n_items, min_item, max_item) || !contiguous(n_pets, min_pet, max_pet) {
|
||||
let detail = format!("the realm has a character with this import's marker (guid {local_guid}) but {n_items} items / {n_pets} pets where the plan had {} / {}", entry.items.len(), entry.pets.len());
|
||||
store.flag_import(import_id, &detail)?;
|
||||
return Err(PortableError::ImportNeedsAttention { import_id, detail });
|
||||
}
|
||||
|
||||
@@ -22,24 +22,24 @@ use super::import::*;
|
||||
use super::plan::{build_plan, PlanContext};
|
||||
use super::*;
|
||||
|
||||
const B: &str = "realm-b";
|
||||
const ACCOUNT: u32 = 7001;
|
||||
pub(super) const B: &str = "realm-b";
|
||||
pub(super) const ACCOUNT: u32 = 7001;
|
||||
|
||||
fn tools(var: &str) -> Option<(PathBuf, u16, String)> {
|
||||
pub(super) fn tools(var: &str) -> Option<(PathBuf, u16, String)> {
|
||||
let spec = std::env::var(var).ok()?;
|
||||
let p: Vec<&str> = spec.split('|').collect();
|
||||
assert_eq!(p.len(), 3, "{var} = <bin dir>|<port>|<password>");
|
||||
Some((PathBuf::from(p[0]), p[1].parse().expect("port"), p[2].to_string()))
|
||||
}
|
||||
|
||||
struct Realms {
|
||||
a: Db,
|
||||
b: Db,
|
||||
pub(super) struct Realms {
|
||||
pub(super) a: Db,
|
||||
pub(super) b: Db,
|
||||
/// A session of the game server's database user, to stand in for a running realm.
|
||||
server_user: Db,
|
||||
pub(super) server_user: Db,
|
||||
}
|
||||
|
||||
fn realms() -> Option<Realms> {
|
||||
pub(super) fn realms() -> Option<Realms> {
|
||||
let (bin_a, port_a, pw_a) = tools("COA_PORTABLE_LIVE")?;
|
||||
let (bin_b, port_b, pw_b) = tools("COA_PORTABLE_LIVE_B")?;
|
||||
Some(Realms {
|
||||
@@ -49,14 +49,14 @@ fn realms() -> Option<Realms> {
|
||||
})
|
||||
}
|
||||
|
||||
fn opts() -> ImportOptions {
|
||||
pub(super) fn opts() -> ImportOptions {
|
||||
ImportOptions { recovery_grace: Duration::ZERO, lock_wait_seconds: 2, ..ImportOptions::default() }
|
||||
}
|
||||
|
||||
/// Put realm B back to its baseline (the characters of `realm-b-fixture.sql` and the template characters): removes every
|
||||
/// character with a guid above the fixture's and what hangs off it. Every live test starts with this, so the tests do not
|
||||
/// depend on each other or on their order.
|
||||
fn reset_b(db: &Db) {
|
||||
pub(super) fn reset_b(db: &Db) {
|
||||
let mut sql = vec!["DROP TRIGGER IF EXISTS acore_characters.coa_test_fail;".to_string()];
|
||||
for (table, column) in super::plan::CHARACTER_KEYED {
|
||||
sql.push(format!("DELETE FROM acore_characters.`{table}` WHERE `{column}` > 3010;"));
|
||||
@@ -70,20 +70,20 @@ fn reset_b(db: &Db) {
|
||||
")).unwrap();
|
||||
}
|
||||
|
||||
fn on_b(store: &Store, id: CharacterId) -> Vec<crate::portable::store::MappingRecord> {
|
||||
pub(super) fn on_b(store: &Store, id: CharacterId) -> Vec<crate::portable::store::MappingRecord> {
|
||||
store.server_mappings(id).unwrap().into_iter().filter(|m| m.server_id == B).collect()
|
||||
}
|
||||
|
||||
fn first(db: &Db, sql: &str) -> String {
|
||||
pub(super) fn first(db: &Db, sql: &str) -> String {
|
||||
db.query(sql).unwrap().lines().next().unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
fn number(db: &Db, sql: &str) -> u64 {
|
||||
pub(super) fn number(db: &Db, sql: &str) -> u64 {
|
||||
first(db, sql).trim().parse().unwrap_or_else(|_| panic!("not a number: {sql}"))
|
||||
}
|
||||
|
||||
/// Row counts of every table of the characters schema: the "nothing changed" proof.
|
||||
fn counts(db: &Db) -> BTreeMap<String, u64> {
|
||||
pub(super) fn counts(db: &Db) -> BTreeMap<String, u64> {
|
||||
let tables = db.tables("acore_characters").unwrap();
|
||||
let sql = tables.iter().map(|t| format!("SELECT '{t}', COUNT(*) FROM acore_characters.`{t}`")).collect::<Vec<_>>().join(" UNION ALL ");
|
||||
db.query(&sql).unwrap().lines().map(|l| {
|
||||
@@ -92,18 +92,18 @@ fn counts(db: &Db) -> BTreeMap<String, u64> {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn fresh_store() -> (Store, crate::portable::ids::ProfileId) {
|
||||
pub(super) fn fresh_store() -> (Store, crate::portable::ids::ProfileId) {
|
||||
let mut store = Store::open_in_memory().unwrap();
|
||||
let profile = store.default_profile().unwrap();
|
||||
(store, profile)
|
||||
}
|
||||
|
||||
fn make(r: &Realms, store: &mut Store, profile: crate::portable::ids::ProfileId, guid: u32) -> CharacterId {
|
||||
pub(super) fn make(r: &Realms, store: &mut Store, profile: crate::portable::ids::ProfileId, guid: u32) -> CharacterId {
|
||||
make_portable(&r.a, store, profile, "realm-a", guid).unwrap().character_id
|
||||
}
|
||||
|
||||
/// What a trip through a realm legitimately does not preserve. Everything else must come back identical.
|
||||
fn expected_after_a_trip(canonical: &PortableCharacter, back: &PortableCharacter) -> PortableCharacter {
|
||||
pub(super) fn expected_after_a_trip(canonical: &PortableCharacter, back: &PortableCharacter) -> PortableCharacter {
|
||||
let mut c = canonical.clone();
|
||||
c.identity.name = back.identity.name.clone(); // a taken or reserved name arrives under a temporary one
|
||||
for item in &mut c.items {
|
||||
@@ -117,10 +117,11 @@ fn expected_after_a_trip(canonical: &PortableCharacter, back: &PortableCharacter
|
||||
c.normalized()
|
||||
}
|
||||
|
||||
fn read_back(r: &Realms, store: &Store, id: CharacterId) -> PortableCharacter {
|
||||
pub(super) fn read_back(r: &Realms, store: &Store, id: CharacterId) -> PortableCharacter {
|
||||
let guid = store.server_mappings(id).unwrap().iter().find(|m| m.server_id == B).unwrap().local_guid;
|
||||
let prior = store.active_item_lookup(id, B).unwrap();
|
||||
export_character(&r.b, guid, Some(id), &prior).unwrap().model
|
||||
let pets = store.active_pet_lookup(id, B).unwrap();
|
||||
export_character_with_pets(&r.b, guid, Some(id), &prior, &pets).unwrap().model
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -511,8 +512,13 @@ fn check_dir() -> PathBuf {
|
||||
PathBuf::from(std::env::var("COA_PORTABLE_SERVER_CHECK_DIR").expect("COA_PORTABLE_SERVER_CHECK_DIR"))
|
||||
}
|
||||
|
||||
/// The staged real-server checks only mean something when run stage by stage with the worldserver in between.
|
||||
fn staged() -> bool {
|
||||
std::env::var("COA_PORTABLE_SERVER_CHECK_DIR").is_ok()
|
||||
}
|
||||
|
||||
/// Paths at which two JSON values differ (capped).
|
||||
fn json_diff(path: &str, a: &serde_json::Value, b: &serde_json::Value, out: &mut Vec<String>) {
|
||||
pub(super) fn json_diff(path: &str, a: &serde_json::Value, b: &serde_json::Value, out: &mut Vec<String>) {
|
||||
use serde_json::Value::{Array, Null, Object};
|
||||
if out.len() >= 60 {
|
||||
return;
|
||||
@@ -539,6 +545,9 @@ fn json_diff(path: &str, a: &serde_json::Value, b: &serde_json::Value, out: &mut
|
||||
#[ignore]
|
||||
fn server_check_1_import_into_the_stopped_realm() {
|
||||
let Some(r) = realms() else { return };
|
||||
if !staged() {
|
||||
return;
|
||||
}
|
||||
reset_b(&r.b);
|
||||
let dir = check_dir();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -560,6 +569,9 @@ fn server_check_1_import_into_the_stopped_realm() {
|
||||
#[ignore]
|
||||
fn server_check_3_what_the_real_server_did_to_them() {
|
||||
let Some(r) = realms() else { return };
|
||||
if !staged() {
|
||||
return;
|
||||
}
|
||||
let store = Store::open(&check_dir()).unwrap();
|
||||
let profile = store.list_characters_all().unwrap();
|
||||
let mut report = Vec::new();
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
//! Live tests of Phase 4 against the two real disposable MySQL servers of `live_import.rs` (realm A = source, realm B =
|
||||
//! destination). No worldserver is involved here; `live_roundtrip.rs` adds the real servers.
|
||||
//!
|
||||
//! ```text
|
||||
//! COA_PORTABLE_LIVE="<bin>|13998|<pw>" COA_PORTABLE_LIVE_B="<bin>|13997|<pw>" cargo test -p coa-core portable::realm::live_update -- --ignored --test-threads=1
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::portable::ids::{CharacterId, PortableItemId, ProfileId};
|
||||
use crate::portable::merge::{merge3, Mode};
|
||||
use crate::portable::model::*;
|
||||
use crate::portable::store::{ImportState, Presence, Store, UpdatePlan};
|
||||
|
||||
use super::live_import::{counts, expected_after_a_trip, first, fresh_store, json_diff, make, number, opts, read_back, realms, reset_b, Realms, ACCOUNT, B};
|
||||
use super::reconcile::resolve_import_update;
|
||||
use super::update::{build_update, UpdateContext};
|
||||
use super::*;
|
||||
|
||||
const EMPTY_ENCHANTS: &str = "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 ";
|
||||
|
||||
fn sql(db: &Db, text: &str) {
|
||||
db.query(text).unwrap_or_else(|e| panic!("{e}\n{text}"));
|
||||
}
|
||||
|
||||
/// Make `a_guid` portable on realm A and import it into B; returns (portable id, local guid on B).
|
||||
fn join(r: &Realms, store: &mut Store, profile: ProfileId, a_guid: u32) -> (CharacterId, u32) {
|
||||
let id = make(r, store, profile, a_guid);
|
||||
let outcome = import_character(&r.b, store, id, B, ACCOUNT, &opts()).unwrap_or_else(|e| panic!("import of {a_guid}: {e}"));
|
||||
(id, outcome.local_guid)
|
||||
}
|
||||
|
||||
/// Everything about a character that is the realm's own business: where it stands, where it is bound, its auras,
|
||||
/// cooldowns and instance saves. An in-place update must leave this exactly as it is.
|
||||
fn world_local(db: &Db, guid: u32) -> Vec<String> {
|
||||
let one = |sql: String| db.query(&sql).unwrap().trim().to_string();
|
||||
vec![
|
||||
one(format!("SELECT CONCAT_WS('|', guid, account, map, zone, position_x, position_y, position_z, orientation, online, at_login, totaltime, logout_time, instance_id, cinematic, extra_flags) FROM acore_characters.characters WHERE guid = {guid}")),
|
||||
one(format!("SELECT CONCAT_WS('|', mapId, zoneId, posX, posY, posZ) FROM acore_characters.character_homebind WHERE guid = {guid}")),
|
||||
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', spell, item, time) ORDER BY spell) FROM acore_characters.character_spell_cooldown WHERE guid = {guid}")),
|
||||
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', quest, time) ORDER BY quest) FROM acore_characters.character_queststatus_daily WHERE guid = {guid}")),
|
||||
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', instance, permanent) ORDER BY instance) FROM acore_characters.character_instance WHERE guid = {guid}")),
|
||||
one(format!("SELECT GROUP_CONCAT(CONCAT_WS('|', spell, remainTime) ORDER BY spell) FROM acore_characters.character_aura WHERE guid = {guid}")),
|
||||
]
|
||||
}
|
||||
|
||||
/// Give a character realm-local state that the portable model does not carry and that differs from anything the import wrote.
|
||||
fn make_local(db: &Db, guid: u32) {
|
||||
sql(
|
||||
db,
|
||||
&format!(
|
||||
"UPDATE acore_characters.characters SET map = 1, zone = 14, position_x = -618.5, position_y = -4251.75, position_z = 38.25, orientation = 1.5, totaltime = 7777, logout_time = 1700000000 WHERE guid = {guid};
|
||||
UPDATE acore_characters.character_homebind SET mapId = 1, zoneId = 14, posX = -600.5, posY = -4200.25, posZ = 40.5 WHERE guid = {guid};
|
||||
INSERT INTO acore_characters.character_spell_cooldown (guid, spell, category, item, time, needSend) VALUES ({guid}, 2096, 0, 0, 2000000000, 0);
|
||||
INSERT INTO acore_characters.character_queststatus_daily (guid, quest, time) VALUES ({guid}, 9999, 1700000000);
|
||||
INSERT INTO acore_characters.character_instance (guid, instance, permanent, extended) VALUES ({guid}, 5, 1, 0);
|
||||
INSERT INTO acore_characters.character_aura (guid, casterGuid, itemGuid, spell, effectMask, recalculateMask, stackCount, maxDuration, remainTime) VALUES ({guid}, 0, 0, 1459, 1, 0, 1, 3600000, 3000000);"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
fn free_slot(db: &Db, guid: u32) -> u8 {
|
||||
let used: Vec<u64> = db.query(&format!("SELECT slot FROM acore_characters.character_inventory WHERE guid = {guid} AND bag = 0")).unwrap().lines().filter_map(|l| l.trim().parse().ok()).collect();
|
||||
(23u8..=38).chain(39..=66).find(|s| !used.contains(&(*s as u64))).expect("a free slot")
|
||||
}
|
||||
|
||||
/// A new item of the realm's own (what a realm hands out, or a player loots), written the way the realm writes it.
|
||||
fn realm_gives(db: &Db, guid: u32, entry: u64) -> u32 {
|
||||
let item = number(db, "SELECT MAX(guid) + 1 FROM acore_characters.item_instance") as u32;
|
||||
let slot = free_slot(db, guid);
|
||||
sql(
|
||||
db,
|
||||
&format!(
|
||||
"INSERT INTO acore_characters.item_instance (guid, itemEntry, owner_guid, count, enchantments) VALUES ({item}, {entry}, {guid}, 1, '{EMPTY_ENCHANTS}');
|
||||
INSERT INTO acore_characters.character_inventory (guid, bag, slot, item) VALUES ({guid}, 0, {slot}, {item});"
|
||||
),
|
||||
);
|
||||
item
|
||||
}
|
||||
|
||||
/// Remove an item from a realm character the way a realm does (inventory row and instance).
|
||||
fn realm_takes(db: &Db, guid: u32, item: u32) {
|
||||
sql(db, &format!("DELETE FROM acore_characters.character_inventory WHERE guid = {guid} AND item = {item}; DELETE FROM acore_characters.item_instance WHERE guid = {item} AND owner_guid = {guid};"));
|
||||
}
|
||||
|
||||
/// The local item guid a portable item has on B.
|
||||
fn local_item(store: &Store, id: CharacterId, item: PortableItemId) -> u32 {
|
||||
store.item_mappings(id, B).unwrap().into_iter().find(|m| m.portable_item_id == item && m.active).unwrap_or_else(|| panic!("item {item} is not mapped")).local_item_guid
|
||||
}
|
||||
|
||||
/// Plain items of a model that hold nothing (safe to take away or hold back).
|
||||
fn plain(m: &PortableCharacter) -> Vec<PortableItemId> {
|
||||
let containers: std::collections::HashSet<PortableItemId> = m.items.iter().filter_map(|i| i.container).collect();
|
||||
m.items.iter().filter(|i| i.container.is_none() && !containers.contains(&i.id) && i.slot >= 23).map(|i| i.id).collect()
|
||||
}
|
||||
|
||||
fn state_of(store: &Store, id: CharacterId) -> (u64, u64) {
|
||||
(store.character(id).unwrap().revision, store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == B).unwrap().last_revision)
|
||||
}
|
||||
|
||||
fn spells_of(r: &Realms, guid: u32) -> Vec<u32> {
|
||||
r.a.query(&format!("SELECT spell FROM acore_characters.character_spell WHERE guid = {guid} ORDER BY spell")).unwrap().lines().filter_map(|l| l.trim().parse().ok()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn an_update_changes_the_portable_subset_in_place_and_keeps_everything_local() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
make_local(&r.b, g);
|
||||
let local_before = world_local(&r.b, g);
|
||||
let counts_before = counts(&r.b);
|
||||
let max_item_before = number(&r.b, "SELECT MAX(guid) FROM acore_characters.item_instance");
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
|
||||
// the canonical character moves on (it was played somewhere else): more money and xp, one item gone, one new, a spell, a quest
|
||||
let extra_spell = *spells_of(&r, 1004).iter().find(|s| !c0.build.spells.iter().any(|(k, _)| k == *s)).expect("a spell 1002 lacks");
|
||||
let gone = plain(&c0)[0];
|
||||
let mut c1 = c0.clone();
|
||||
c1.progression.money += 123_456;
|
||||
c1.progression.xp += 1_000;
|
||||
c1.progression.honor.total_kills += 7;
|
||||
c1.items.retain(|i| i.id != gone);
|
||||
let mut loot = c0.items.iter().find(|i| i.id == plain(&c0)[1]).unwrap().clone();
|
||||
loot.id = PortableItemId::new();
|
||||
loot.slot = 66;
|
||||
c1.items.push(loot.clone());
|
||||
c1.build.spells.push((extra_spell, 255));
|
||||
c1.quests.rewarded.push(4242);
|
||||
c1.reputation.push(ReputationEntry { faction: 69, standing: 300, flags: 1 });
|
||||
let c1 = c1.normalized();
|
||||
store.commit_snapshot(id, 1, c1.clone(), "realm-a", Some("played elsewhere")).unwrap();
|
||||
|
||||
let outcome = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap();
|
||||
assert!(outcome.updated);
|
||||
assert_eq!((outcome.from_revision, outcome.to_revision), (1, 2));
|
||||
assert_eq!((outcome.counts.items_added, outcome.counts.items_removed), (1, 1), "{:?}", outcome.counts);
|
||||
|
||||
// the same character, in place: guid, mapping, and every piece of realm-local state are untouched
|
||||
assert_eq!(world_local(&r.b, g), local_before, "position, homebind, auras, cooldowns and instance saves are the realm's own");
|
||||
assert_eq!(store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == B).unwrap().local_guid, g);
|
||||
assert_eq!(state_of(&store, id), (2, 2));
|
||||
let after = counts(&r.b);
|
||||
let changed: BTreeMap<_, _> = after.iter().filter(|(t, n)| counts_before[*t] != **n).map(|(t, n)| (t.clone(), (counts_before[t], *n))).collect();
|
||||
assert_eq!(
|
||||
changed.keys().map(String::as_str).collect::<Vec<_>>(),
|
||||
vec!["character_queststatus_rewarded", "character_reputation", "character_settings", "character_spell"],
|
||||
"only the tables the change is about gained rows: {changed:?}"
|
||||
);
|
||||
assert_eq!(after["characters"], counts_before["characters"], "the character is never deleted or re-created");
|
||||
assert_eq!(after["item_instance"], counts_before["item_instance"], "one item out, one in");
|
||||
|
||||
// the realm now holds exactly the new canonical character
|
||||
let back = read_back(&r, &store, id);
|
||||
let mut diffs = Vec::new();
|
||||
json_diff("", &serde_json::to_value(expected_after_a_trip(&c1, &back)).unwrap(), &serde_json::to_value(&back).unwrap(), &mut diffs);
|
||||
assert!(diffs.is_empty(), "{diffs:#?}");
|
||||
// the removed item's mapping is retired, the new one is mapped to a guid above everything the realm names
|
||||
let mapped = store.item_mappings(id, B).unwrap();
|
||||
assert!(mapped.iter().all(|m| m.portable_item_id != gone));
|
||||
assert!(local_item(&store, id, loot.id) as u64 > max_item_before, "the new item's guid is allocated above every item guid of the realm");
|
||||
// nothing is left unfinished, and asking again is a no-op
|
||||
assert!(store.open_imports(B).unwrap().is_empty());
|
||||
let again = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap();
|
||||
assert!(!again.updated);
|
||||
assert_eq!(counts(&r.b), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn a_failed_update_changes_not_one_row() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
make_local(&r.b, g);
|
||||
let before = counts(&r.b);
|
||||
let local_before = world_local(&r.b, g);
|
||||
let mut c1 = store.load_current(id).unwrap();
|
||||
c1.progression.money += 5;
|
||||
c1.build.spells.push((500_090, 255));
|
||||
store.commit_snapshot(id, 1, c1.normalized(), "realm-a", None).unwrap();
|
||||
|
||||
r.b.query("DROP TRIGGER IF EXISTS acore_characters.coa_test_fail").unwrap();
|
||||
r.b.query("DELIMITER //\nCREATE TRIGGER acore_characters.coa_test_fail BEFORE INSERT ON acore_characters.character_spell FOR EACH ROW BEGIN IF NEW.spell = 500090 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'injected failure'; END IF; END//\nDELIMITER ;").unwrap();
|
||||
let result = update_realm_character(&r.b, &mut store, id, B, &opts());
|
||||
r.b.query("DROP TRIGGER IF EXISTS acore_characters.coa_test_fail").unwrap();
|
||||
let error = result.expect_err("the injected failure must fail the update").to_string();
|
||||
assert!(error.contains("injected failure") || error.contains("database command failed"), "{error}");
|
||||
|
||||
assert_eq!(counts(&r.b), before);
|
||||
assert_eq!(world_local(&r.b, g), local_before);
|
||||
assert_eq!(number(&r.b, &format!("SELECT money FROM acore_characters.characters WHERE guid = {g}")), store.load_snapshot(id, 1).unwrap().progression.money as u64, "the money change that ran before the failure was rolled back");
|
||||
assert_eq!(state_of(&store, id), (2, 1), "no mapping or revision moved");
|
||||
assert!(store.open_imports(B).unwrap().is_empty(), "the journal entry is closed");
|
||||
let entries = r.b.query("SELECT GET_LOCK('coa_portable_import', 0)").unwrap();
|
||||
assert_eq!(entries.trim(), "1", "the import lock was released");
|
||||
r.b.query("DO RELEASE_LOCK('coa_portable_import')").unwrap();
|
||||
// and the update works once the cause is gone
|
||||
let outcome = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap();
|
||||
assert!(outcome.updated);
|
||||
assert_eq!(state_of(&store, id), (2, 2));
|
||||
}
|
||||
|
||||
/// The update flow of `update_realm_character` up to (not including) the realm run, so a test can interrupt it.
|
||||
fn prepare_update(r: &Realms, store: &mut Store, id: CharacterId) -> (crate::portable::ids::ImportId, String) {
|
||||
let guid = store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == B).unwrap().local_guid;
|
||||
let prior_items = store.active_item_lookup(id, B).unwrap();
|
||||
let prior_pets = store.active_pet_lookup(id, B).unwrap();
|
||||
let exported = export_character_with_pets(&r.b, guid, Some(id), &prior_items, &prior_pets).unwrap();
|
||||
let synced = store.synced_model(id, B).unwrap().unwrap();
|
||||
let canonical = store.load_current(id).unwrap();
|
||||
let merged = merge3(&exported.model, &synced, &canonical, Mode::Strict).unwrap();
|
||||
assert!(merged.conflicts.is_empty());
|
||||
let items = exported.observations.iter().map(|o| (o.portable_item_id, o.local_item_guid)).collect();
|
||||
let pets = exported.pet_observations.iter().map(|o| (o.portable_pet_id, o.local_pet_number)).collect();
|
||||
let schema = probe(&r.b).unwrap();
|
||||
let users = opts().game_server_users;
|
||||
let revision = canonical_revision(store, id);
|
||||
let ctx = |nonce| UpdateContext { ruleset: Ruleset::Coa, local_guid: guid, revision, nonce, game_server_users: &users, probe: &schema, items: &items, pets: &pets };
|
||||
let draft = build_update(&exported.model, &merged.model, &ctx([0; 4])).unwrap();
|
||||
let by_id: std::collections::HashMap<_, _> = merged.model.items.iter().map(|i| (i.id, i)).collect();
|
||||
let plan = UpdatePlan {
|
||||
added_items: draft.added_items.iter().map(|i| crate::portable::store::PlannedItem { id: *i, entry: by_id[i].entry.clone(), identity: crate::portable::identity::item_identity(&by_id[i].entry, by_id[i].random_property_id) }).collect(),
|
||||
retired_items: draft.removed_items.clone(),
|
||||
..UpdatePlan::default()
|
||||
};
|
||||
let ticket = store.begin_update(id, B, revision, plan).unwrap();
|
||||
let script = build_update(&exported.model, &merged.model, &ctx(ticket.nonce)).unwrap();
|
||||
(ticket.import_id, script.script)
|
||||
}
|
||||
|
||||
fn canonical_revision(store: &Store, id: CharacterId) -> u64 {
|
||||
store.character(id).unwrap().revision
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn an_update_whose_answer_was_lost_is_finished_by_recovery_and_one_that_never_ran_is_aborted() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
let gone = plain(&c0)[0];
|
||||
let mut c1 = c0.clone();
|
||||
c1.progression.money += 10;
|
||||
c1.items.retain(|i| i.id != gone);
|
||||
let mut loot = c0.items.iter().find(|i| i.id == plain(&c0)[1]).unwrap().clone();
|
||||
loot.id = PortableItemId::new();
|
||||
loot.slot = 66;
|
||||
c1.items.push(loot.clone());
|
||||
store.commit_snapshot(id, 1, c1.normalized(), "realm-a", None).unwrap();
|
||||
|
||||
// (a) the realm commits, the Manager never hears of it
|
||||
let (import_id, script) = prepare_update(&r, &mut store, id);
|
||||
assert!(matches!(store.begin_update(id, B, 2, UpdatePlan::default()), Err(PortableError::ImportInProgress { .. })), "one unfinished update per pair");
|
||||
r.b.query(&script).unwrap();
|
||||
assert_eq!(state_of(&store, id), (2, 1), "the Manager still believes the realm is at revision 1");
|
||||
assert!(store.item_mappings(id, B).unwrap().iter().any(|m| m.portable_item_id == gone), "and still maps the removed item");
|
||||
let reports = recover_imports(&r.b, &mut store, B, &opts()).unwrap();
|
||||
assert_eq!(reports.len(), 1);
|
||||
assert!(matches!(reports[0].resolution, Resolution::Committed(a) if a.local_guid == g), "{:?}", reports[0].resolution);
|
||||
assert_eq!(state_of(&store, id), (2, 2));
|
||||
assert!(store.item_mappings(id, B).unwrap().iter().all(|m| m.portable_item_id != gone));
|
||||
let new_guid = local_item(&store, id, loot.id);
|
||||
assert_eq!(number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE guid = {new_guid} AND owner_guid = {g}")), 1, "the recorded allocation is the realm's");
|
||||
assert_eq!(store.import_entry(import_id).unwrap().state, ImportState::Committed);
|
||||
assert!(recover_imports(&r.b, &mut store, B, &opts()).unwrap().is_empty());
|
||||
|
||||
// (b) the Manager journaled the update and died before the realm ran it
|
||||
let mut c2 = store.load_current(id).unwrap();
|
||||
c2.progression.money += 1;
|
||||
store.commit_snapshot(id, 2, c2, "realm-a", None).unwrap();
|
||||
let before = counts(&r.b);
|
||||
let (import_id, _script_never_run) = prepare_update(&r, &mut store, id);
|
||||
let reports = recover_imports(&r.b, &mut store, B, &opts()).unwrap();
|
||||
assert!(matches!(reports[0].resolution, Resolution::Aborted), "{:?}", reports[0].resolution);
|
||||
assert_eq!(store.import_entry(import_id).unwrap().state, ImportState::Aborted);
|
||||
assert_eq!(counts(&r.b), before);
|
||||
assert_eq!(state_of(&store, id), (3, 2));
|
||||
// the update can simply be done again
|
||||
assert!(update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap().updated);
|
||||
assert_eq!(state_of(&store, id), (3, 3));
|
||||
// a resolved entry resolves the same way again
|
||||
assert!(matches!(resolve_import_update(&r.b, &mut store, import_id, &opts(), false).unwrap(), Resolution::Aborted));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn an_update_is_refused_while_the_realm_runs_the_character_is_online_or_a_session_is_open() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
let mut c1 = store.load_current(id).unwrap();
|
||||
c1.progression.money += 1;
|
||||
store.commit_snapshot(id, 1, c1.normalized(), "realm-a", None).unwrap();
|
||||
let before = counts(&r.b);
|
||||
|
||||
// a session of the game server's database user = the realm is running
|
||||
let refused = std::thread::scope(|s| {
|
||||
let holder = s.spawn(|| {
|
||||
let _ = r.server_user.query("SELECT SLEEP(6)");
|
||||
});
|
||||
std::thread::sleep(std::time::Duration::from_millis(1500));
|
||||
let result = update_realm_character(&r.b, &mut store, id, B, &opts());
|
||||
holder.join().unwrap();
|
||||
result
|
||||
});
|
||||
assert!(matches!(&refused, Err(PortableError::ImportRefused(p)) if p.iter().any(|p| matches!(p, ImportProblem::RealmRunning { .. }))), "{refused:?}");
|
||||
assert_eq!(counts(&r.b), before);
|
||||
assert!(store.open_imports(B).unwrap().is_empty(), "refused before anything was journaled");
|
||||
|
||||
// a character that is online cannot be read, so it cannot be updated either
|
||||
sql(&r.b, &format!("UPDATE acore_characters.characters SET online = 1 WHERE guid = {g}"));
|
||||
assert!(matches!(update_realm_character(&r.b, &mut store, id, B, &opts()), Err(PortableError::NotExportable(_))));
|
||||
sql(&r.b, "UPDATE acore_characters.characters SET online = 0");
|
||||
|
||||
// a session cannot start from an old base: the realm is at revision 1, the canonical character at 2
|
||||
let started = begin_session(&r.b, &mut store, id, B);
|
||||
assert!(matches!(started, Err(PortableError::StaleRevision { expected: 1, current: 2 })), "{started:?}");
|
||||
assert!(update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap().updated);
|
||||
|
||||
// an open session blocks updates: reconcile first
|
||||
begin_session(&r.b, &mut store, id, B).unwrap();
|
||||
let mut c = store.load_current(id).unwrap();
|
||||
c.progression.money += 1;
|
||||
store.commit_snapshot(id, 2, c.normalized(), "realm-a", None).unwrap();
|
||||
assert!(matches!(update_realm_character(&r.b, &mut store, id, B, &opts()), Err(PortableError::SessionOpen)));
|
||||
// and a session whose canonical character moved under it cannot be reconciled
|
||||
let r2 = reconcile_session(&r.b, &mut store, id, B, true, None);
|
||||
assert!(matches!(r2, Err(PortableError::StaleRevision { expected: 2, current: 3 })), "{r2:?}");
|
||||
assert_eq!(store.character(id).unwrap().revision, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn both_sides_changing_the_same_thing_differently_is_a_conflict_and_nothing_is_written() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
let faction = c0.reputation[0].faction;
|
||||
let standing = c0.reputation[0].standing;
|
||||
// the realm raised a reputation (played without a session) while the canonical character raised it differently
|
||||
sql(&r.b, &format!("UPDATE acore_characters.character_reputation SET standing = {} WHERE guid = {g} AND faction = {faction}", standing + 111));
|
||||
let mut c1 = c0.clone();
|
||||
c1.reputation.iter_mut().find(|e| e.faction == faction).unwrap().standing = standing + 222;
|
||||
c1.progression.money += 77;
|
||||
store.commit_snapshot(id, 1, c1.normalized(), "realm-a", None).unwrap();
|
||||
let before = counts(&r.b);
|
||||
let result = update_realm_character(&r.b, &mut store, id, B, &opts());
|
||||
match &result {
|
||||
Err(PortableError::UpdateConflicts(list)) => assert!(list.iter().any(|c| c.contains("reputation")), "{list:?}"),
|
||||
other => panic!("expected a conflict, got {other:?}"),
|
||||
}
|
||||
assert_eq!(counts(&r.b), before);
|
||||
assert_eq!(number(&r.b, &format!("SELECT money FROM acore_characters.characters WHERE guid = {g}")), c0.progression.money as u64, "not even the money, which would not have conflicted, was written");
|
||||
assert!(store.open_imports(B).unwrap().is_empty());
|
||||
assert_eq!(state_of(&store, id), (2, 1));
|
||||
|
||||
// where only one side changed a thing, both changes survive: the realm's own reputation change and the canonical money
|
||||
let mut c2 = c0.clone();
|
||||
c2.progression.money += 77;
|
||||
store.commit_snapshot(id, 2, c2.normalized(), "realm-a", None).unwrap();
|
||||
let out = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap();
|
||||
assert!(out.updated);
|
||||
assert_eq!(number(&r.b, &format!("SELECT money FROM acore_characters.characters WHERE guid = {g}")), c0.progression.money as u64 + 77);
|
||||
assert_eq!(first(&r.b, &format!("SELECT standing FROM acore_characters.character_reputation WHERE guid = {g} AND faction = {faction}")).trim().parse::<i64>().unwrap(), (standing + 111) as i64, "the realm's own unreconciled change is left alone");
|
||||
}
|
||||
|
||||
/// The central scenario: a realm filters items before the baseline, the player later gets rid of others and finds new ones.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn filtered_items_stay_canonical_and_deleted_ones_do_not_and_a_checkpoint_never_counts_twice() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
let candidates = plain(&c0);
|
||||
let (held_back, sold_later, kept) = (&candidates[0..3], candidates[3], candidates[4]);
|
||||
|
||||
// --- the realm's own first load/save: it holds three items back, hands out one of its own, adds default spells, resets honor ---
|
||||
let extra_spells: Vec<u32> = spells_of(&r, 1004).into_iter().filter(|s| !c0.build.spells.iter().any(|(k, _)| k == s)).take(2).collect();
|
||||
for item in held_back {
|
||||
let guid = local_item(&store, id, *item);
|
||||
sql(&r.b, &format!("DELETE FROM acore_characters.character_inventory WHERE guid = {g} AND item = {guid}"));
|
||||
// a realm mails what it cannot place: the instance row stays, owned by the character, in a mail
|
||||
sql(&r.b, &format!("INSERT INTO acore_characters.mail_items (mail_id, item_guid, receiver) VALUES (99000, {guid}, {g})"));
|
||||
}
|
||||
let realm_own = realm_gives(&r.b, g, c0.items.iter().find(|i| i.id == kept).unwrap().entry.id());
|
||||
for s in &extra_spells {
|
||||
sql(&r.b, &format!("INSERT INTO acore_characters.character_spell (guid, spell, specMask) VALUES ({g}, {s}, 255)"));
|
||||
}
|
||||
sql(&r.b, &format!("UPDATE acore_characters.characters SET todayHonorPoints = 0, yesterdayHonorPoints = 0, chosenTitle = 0 WHERE guid = {g}"));
|
||||
|
||||
let start = begin_session(&r.b, &mut store, id, B).unwrap();
|
||||
assert_eq!(start.c0_revision, 1);
|
||||
assert_eq!(start.items_filtered, 3, "the three held-back items are recognised as filtered, not as deleted");
|
||||
assert_eq!(start.items_realm_local, 1, "the realm's own item is recognised as the realm's");
|
||||
let mapped = store.item_mappings(id, B).unwrap();
|
||||
for item in held_back {
|
||||
let m = mapped.iter().find(|m| m.portable_item_id == *item).unwrap();
|
||||
assert!(m.active && m.presence == Presence::Filtered, "an item that is absent from the inventory at B0 is not retired");
|
||||
}
|
||||
assert!(mapped.iter().any(|m| m.local_item_guid == realm_own && m.presence == Presence::RealmLocal));
|
||||
|
||||
// --- the player plays: gets rid of one item, finds one, earns money ---
|
||||
let sold = local_item(&store, id, sold_later);
|
||||
realm_takes(&r.b, g, sold);
|
||||
let loot_entry = c0.items.iter().find(|i| i.id == candidates[5]).unwrap().entry.id();
|
||||
let loot_guid = realm_gives(&r.b, g, loot_entry);
|
||||
sql(&r.b, &format!("UPDATE acore_characters.characters SET money = money + 1000, totalKills = totalKills + 4 WHERE guid = {g}"));
|
||||
|
||||
let first = reconcile_session(&r.b, &mut store, id, B, false, Some("checkpoint 1")).unwrap();
|
||||
assert_eq!(first.revision, 2);
|
||||
let c1 = store.load_current(id).unwrap();
|
||||
assert!(held_back.iter().all(|h| c1.items.iter().any(|i| i.id == *h)), "everything the realm held back is still canonical");
|
||||
assert!(c1.items.iter().all(|i| i.id != sold_later), "the item the player got rid of is gone");
|
||||
assert!(c1.items.iter().any(|i| i.entry.id() == loot_entry && !c0.items.iter().any(|o| o.id == i.id)), "the loot is canonical");
|
||||
assert_eq!(c1.items.len(), c0.items.len() - 1 + 1, "three filtered stay, one sold, one found; the realm's own item does not count");
|
||||
assert_eq!(c1.progression.money, c0.progression.money + 1000);
|
||||
assert_eq!(c1.progression.honor.total_kills, c0.progression.honor.total_kills + 4);
|
||||
for s in &extra_spells {
|
||||
assert!(!c1.build.spells.iter().any(|(k, _)| k == s), "a spell the realm added by itself is normalisation, not progress");
|
||||
}
|
||||
assert_eq!(c1.progression.honor.today_honor, c0.progression.honor.today_honor, "the realm's honor reset does not overwrite the canonical value");
|
||||
assert_eq!(c1.progression.chosen_title, c0.progression.chosen_title, "the realm's title normalisation does not overwrite the canonical value");
|
||||
|
||||
// --- more play, a second checkpoint: only the whole B0 -> B1 delta counts, once ---
|
||||
sql(&r.b, &format!("UPDATE acore_characters.characters SET money = money + 50 WHERE guid = {g}"));
|
||||
let second = reconcile_session(&r.b, &mut store, id, B, false, Some("checkpoint 2")).unwrap();
|
||||
assert_eq!(second.revision, 3);
|
||||
let c2 = store.load_current(id).unwrap();
|
||||
assert_eq!(c2.progression.money, c0.progression.money + 1050, "1000 was not counted again");
|
||||
assert_eq!(c2.items.len(), c1.items.len());
|
||||
// nothing played: no new revision
|
||||
assert_eq!(reconcile_session(&r.b, &mut store, id, B, false, None).unwrap().revision, 3);
|
||||
assert_eq!(store.list_revisions(id).unwrap().len(), 3);
|
||||
|
||||
// --- the item the player found is mapped, the realm's own one stays the realm's, the filtered ones stay mapped ---
|
||||
let mapped = store.item_mappings(id, B).unwrap();
|
||||
assert!(mapped.iter().any(|m| m.local_item_guid == loot_guid && m.presence == Presence::Present));
|
||||
assert!(mapped.iter().any(|m| m.local_item_guid == realm_own && m.presence == Presence::RealmLocal));
|
||||
assert!(held_back.iter().all(|h| mapped.iter().any(|m| m.portable_item_id == *h && m.active && m.presence == Presence::Filtered)));
|
||||
assert!(mapped.iter().all(|m| m.portable_item_id != sold_later || !m.active));
|
||||
|
||||
// --- closing the session, then an in-place update with a newer canonical state ---
|
||||
let last = reconcile_session(&r.b, &mut store, id, B, true, Some("end")).unwrap();
|
||||
assert_eq!(last.revision, 3);
|
||||
assert!(store.open_baseline(id, B).unwrap().is_none());
|
||||
let mut c3 = c2.clone();
|
||||
c3.progression.money += 5;
|
||||
store.commit_snapshot(id, 3, c3.normalized(), "realm-a", Some("played on another realm")).unwrap();
|
||||
let before_items = number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = {g}"));
|
||||
let updated = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap();
|
||||
assert!(updated.updated);
|
||||
assert_eq!(number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = {g}")), before_items, "nothing was added or removed");
|
||||
assert_eq!(number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE guid = {realm_own} AND owner_guid = {g}")), 1, "the realm's own item survived the update");
|
||||
for item in held_back {
|
||||
let guid = local_item(&store, id, *item);
|
||||
assert_eq!(number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.mail_items WHERE item_guid = {guid}")), 1, "a held-back item is not resurrected into the inventory by an update");
|
||||
assert_eq!(number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.character_inventory WHERE item = {guid}")), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn pets_keep_their_ids_across_sessions_and_a_deleted_pet_leaves_the_realm_on_update() {
|
||||
let Some(r) = realms() else { return };
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
let (id, g) = join(&r, &mut store, profile, 1005);
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
assert_eq!(c0.pets.len(), 3);
|
||||
let numbers: Vec<u32> = store.pet_mappings(id, B).unwrap().iter().map(|m| m.local_pet_number).collect();
|
||||
assert_eq!(numbers.len(), 3);
|
||||
|
||||
begin_session(&r.b, &mut store, id, B).unwrap();
|
||||
// the player levels one pet, abandons another
|
||||
sql(&r.b, &format!("UPDATE acore_characters.character_pet SET level = level + 1, exp = 99, name = 'Renamed' WHERE id = {}", numbers[0]));
|
||||
sql(&r.b, &format!("DELETE FROM acore_characters.character_pet WHERE id = {0}; DELETE FROM acore_characters.pet_spell WHERE guid = {0};", numbers[1]));
|
||||
let out = reconcile_session(&r.b, &mut store, id, B, true, None).unwrap();
|
||||
assert_eq!(out.revision, 2);
|
||||
let c1 = store.load_current(id).unwrap();
|
||||
assert_eq!(c1.pets.len(), 2);
|
||||
let pet0 = c1.pets.iter().find(|p| p.id == c0.pets[0].id).expect("the same portable pet");
|
||||
assert_eq!((pet0.level, pet0.exp, pet0.name.as_str()), (c0.pets[0].level + 1, 99, "Renamed"), "the same pet id, with the played changes");
|
||||
assert!(c1.pets.iter().all(|p| p.id != c0.pets[1].id), "the abandoned pet is gone");
|
||||
assert!(c1.pets.iter().any(|p| p.id == c0.pets[2].id));
|
||||
assert_eq!(store.active_pet_lookup(id, B).unwrap().len(), 2);
|
||||
|
||||
// canonical pet 0 moves on elsewhere; the update changes the same realm pet row in place
|
||||
let mut c2 = c1.clone();
|
||||
c2.pets.iter_mut().find(|p| p.id == c0.pets[0].id).unwrap().exp = 500;
|
||||
store.commit_snapshot(id, 2, c2, "realm-a", None).unwrap();
|
||||
assert!(update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap().updated);
|
||||
assert_eq!(number(&r.b, &format!("SELECT exp FROM acore_characters.character_pet WHERE id = {}", numbers[0])), 500, "the same pet number, updated in place");
|
||||
assert_eq!(number(&r.b, &format!("SELECT COUNT(*) FROM acore_characters.character_pet WHERE owner = {g}")), 2);
|
||||
|
||||
// a pet number the realm recycles is not the old pet
|
||||
sql(&r.b, &format!("DELETE FROM acore_characters.character_pet WHERE id = {0}; DELETE FROM acore_characters.pet_spell WHERE guid = {0};", numbers[0]));
|
||||
sql(&r.b, &format!("INSERT INTO acore_characters.character_pet (id, entry, owner, modelid, CreatedBySpell, PetType, level, exp, Reactstate, name, renamed, slot, curhealth, curmana, curhappiness, savetime) VALUES ({}, 42, {g}, 1, 1515, 0, 5, 0, 1, 'Stranger', 0, 4, 100, 0, 0, 1)", numbers[0]));
|
||||
let c = store.load_current(id).unwrap();
|
||||
let before = c.pets.len();
|
||||
store.commit_snapshot(id, 2, c, "realm-a", None).ok();
|
||||
let exported = export_character_with_pets(&r.b, g, Some(id), &store.active_item_lookup(id, B).unwrap(), &store.active_pet_lookup(id, B).unwrap()).unwrap();
|
||||
assert!(exported.model.pets.iter().all(|p| p.id != c0.pets[0].id), "the recycled number does not inherit the old pet's portable id");
|
||||
assert_eq!(exported.model.pets.len(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn the_complete_round_trip_without_a_server() {
|
||||
let Some(r) = realms() else { return };
|
||||
// this test updates the ORIGINAL character of realm A in place, which changes the fixture the recorded-answer tests compare
|
||||
// with: it only runs on request, and realm A has to be reset afterwards (`tools/reset_live_realm.ps1`)
|
||||
if std::env::var("COA_PORTABLE_LIVE_MUTATE_A").is_err() {
|
||||
return;
|
||||
}
|
||||
reset_b(&r.b);
|
||||
let (mut store, profile) = fresh_store();
|
||||
// A -> B
|
||||
let (id, g) = join(&r, &mut store, profile, 1002);
|
||||
// realm A's fixture keeps one character flagged online on purpose (it must not be exportable); a stopped realm has none
|
||||
sql(&r.a, "UPDATE acore_characters.characters SET online = 0 WHERE guid = 1006");
|
||||
let a_guid = store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == "realm-a").unwrap().local_guid;
|
||||
make_local(&r.b, g);
|
||||
let b_local = world_local(&r.b, g);
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
begin_session(&r.b, &mut store, id, B).unwrap();
|
||||
// play on B
|
||||
let new_spell = *spells_of(&r, 1004).iter().find(|s| !c0.build.spells.iter().any(|(k, _)| k == *s)).unwrap();
|
||||
sql(&r.b, &format!("UPDATE acore_characters.characters SET money = money + 4000 WHERE guid = {g}; INSERT INTO acore_characters.character_spell (guid, spell, specMask) VALUES ({g}, {new_spell}, 255)"));
|
||||
let sold = local_item(&store, id, plain(&c0)[0]);
|
||||
realm_takes(&r.b, g, sold);
|
||||
// reconcile into canonical, then update A in place
|
||||
let out = reconcile_session(&r.b, &mut store, id, B, true, Some("session on B")).unwrap();
|
||||
assert_eq!(out.revision, 2);
|
||||
let a_before = world_local(&r.a, a_guid);
|
||||
let a_counts = counts(&r.a);
|
||||
let canonical = store.load_current(id).unwrap();
|
||||
assert_eq!(canonical.progression.money, c0.progression.money + 4000);
|
||||
// A is the realm the character came from: its mapping is synced at revision 1
|
||||
let updated = update_realm_character(&r.a, &mut store, id, "realm-a", &opts()).unwrap();
|
||||
assert!(updated.updated, "{updated:?}");
|
||||
assert_eq!(world_local(&r.a, a_guid), a_before, "A's own position, homebind and local data are untouched");
|
||||
assert_eq!(number(&r.a, &format!("SELECT money FROM acore_characters.characters WHERE guid = {a_guid}")), canonical.progression.money as u64);
|
||||
assert_eq!(number(&r.a, &format!("SELECT COUNT(*) FROM acore_characters.character_spell WHERE guid = {a_guid} AND spell = {new_spell}")), 1);
|
||||
assert_eq!(counts(&r.a)["characters"], a_counts["characters"]);
|
||||
// B is untouched by what happened on A, and still the same local character
|
||||
assert_eq!(world_local(&r.b, g), b_local);
|
||||
// A holds exactly the canonical character
|
||||
let prior = store.active_item_lookup(id, "realm-a").unwrap();
|
||||
let back_a = export_character_with_pets(&r.a, a_guid, Some(id), &prior, &store.active_pet_lookup(id, "realm-a").unwrap()).unwrap().model;
|
||||
let mut diffs = Vec::new();
|
||||
json_diff("", &serde_json::to_value(&canonical).unwrap(), &serde_json::to_value(&back_a).unwrap(), &mut diffs);
|
||||
assert!(diffs.is_empty(), "A holds exactly the canonical character: {diffs:#?}");
|
||||
|
||||
// play on A (a session of its own), reconcile, and go back to B: the SAME local character there, updated in place
|
||||
begin_session(&r.a, &mut store, id, "realm-a").unwrap();
|
||||
sql(&r.a, &format!("UPDATE acore_characters.characters SET money = money + 1000 WHERE guid = {a_guid}"));
|
||||
let out = reconcile_session(&r.a, &mut store, id, "realm-a", true, Some("session on A")).unwrap();
|
||||
assert_eq!(out.revision, 3);
|
||||
let b_counts = counts(&r.b);
|
||||
let back = update_realm_character(&r.b, &mut store, id, B, &opts()).unwrap();
|
||||
assert!(back.updated);
|
||||
assert_eq!((back.from_revision, back.to_revision), (2, 3));
|
||||
assert_eq!(world_local(&r.b, g), b_local, "returning to a visited realm keeps its local character, position and homebind");
|
||||
assert_eq!(number(&r.b, &format!("SELECT money FROM acore_characters.characters WHERE guid = {g}")), canonical.progression.money as u64 + 1000);
|
||||
assert_eq!(counts(&r.b)["characters"], b_counts["characters"], "never deleted and re-created");
|
||||
assert_eq!(store.server_mappings(id).unwrap().into_iter().find(|m| m.server_id == B).unwrap().local_guid, g);
|
||||
assert!(store.open_imports(B).unwrap().is_empty() && store.open_imports("realm-a").unwrap().is_empty());
|
||||
sql(&r.a, "UPDATE acore_characters.characters SET online = 1 WHERE guid = 1006");
|
||||
}
|
||||
@@ -14,9 +14,11 @@ pub mod export;
|
||||
pub mod import;
|
||||
pub mod plan;
|
||||
pub mod policy;
|
||||
pub mod reconcile;
|
||||
pub mod registry;
|
||||
pub mod script;
|
||||
pub mod sqlenc;
|
||||
pub mod update;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -24,13 +26,14 @@ use crate::db::Db;
|
||||
use crate::realms::Mode;
|
||||
|
||||
use super::error::{PortableError, Result};
|
||||
use super::ids::{CharacterId, PortableItemId, ProfileId};
|
||||
use super::ids::{CharacterId, PortableItemId, PortablePetId, ProfileId};
|
||||
use super::model::Ruleset;
|
||||
use super::store::{RealmRegistration, Store};
|
||||
|
||||
pub use blockers::Blocker;
|
||||
pub use export::{build, ExportRequest, Exported};
|
||||
pub use import::{import_character, preflight, recover_imports, resolve_import, ImportOptions, ImportOutcome, ImportProblem, PreflightReport, Resolution};
|
||||
pub use reconcile::{begin_session, reconcile_session, resolve_import_update, update_realm_character, ReconcileOutcome, SessionStart, UpdateOutcome};
|
||||
pub use script::SchemaProbe;
|
||||
|
||||
/// The ruleset a database belongs to is decided by which realm profile it is (`realms::Mode`), never by the caller.
|
||||
@@ -145,9 +148,14 @@ pub fn read_raw(db: &Db, local_guid: u32, probe: &SchemaProbe) -> Result<script:
|
||||
/// "Export snapshot": the portable model of one offline character, plus the item observations that go with it.
|
||||
/// `character_id` and `prior_items` are `None`/empty for a character that is not portable yet.
|
||||
pub fn export_character(db: &Db, local_guid: u32, character_id: Option<CharacterId>, prior_items: &HashMap<u32, (PortableItemId, String)>) -> Result<Exported> {
|
||||
export_character_with_pets(db, local_guid, character_id, prior_items, &HashMap::new())
|
||||
}
|
||||
|
||||
/// The same, for a character that already has pet mappings on this realm: its pets keep their portable ids.
|
||||
pub fn export_character_with_pets(db: &Db, local_guid: u32, character_id: Option<CharacterId>, prior_items: &HashMap<u32, (PortableItemId, String)>, prior_pets: &HashMap<u32, (PortablePetId, String)>) -> Result<Exported> {
|
||||
let probe = probe(db)?;
|
||||
let raw = read_raw(db, local_guid, &probe)?;
|
||||
export::build(&raw, &ExportRequest { ruleset: ruleset_of(db), local_guid, character_id, prior_items })
|
||||
export::build(&raw, &ExportRequest { ruleset: ruleset_of(db), local_guid, character_id, prior_items, prior_pets })
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -176,6 +184,7 @@ pub fn register(store: &mut Store, profile: ProfileId, server_id: &str, exported
|
||||
server_id,
|
||||
local_guid: exported.local_guid,
|
||||
observations: &exported.observations,
|
||||
pets: &exported.pet_observations,
|
||||
})?;
|
||||
Ok(MadePortable { character_id, revision: 1, warnings: exported.warnings })
|
||||
}
|
||||
@@ -187,4 +196,8 @@ mod live;
|
||||
#[cfg(test)]
|
||||
mod live_import;
|
||||
#[cfg(test)]
|
||||
mod live_update;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod update_tests;
|
||||
@@ -28,7 +28,7 @@ pub const NAME_COLUMN_CHARS: u32 = 25;
|
||||
const FULL: u64 = 2_000_000_000;
|
||||
|
||||
/// Raises an SQL error ("subquery returns more than 1 row"): used as the failing branch of an assertion.
|
||||
const FAIL: &str = "(SELECT 1 UNION ALL SELECT 2)";
|
||||
pub(super) const FAIL: &str = "(SELECT 1 UNION ALL SELECT 2)";
|
||||
|
||||
/// Per-character tables whose rows are keyed by the **character guid**. Rows left there by a deleted character with
|
||||
/// the same guid are removed before the import (the realm's own delete leaves module tables behind). Curated by hand:
|
||||
@@ -179,23 +179,23 @@ pub fn marker_data(nonce: [u32; 4], revision: u64) -> String {
|
||||
format!("{} {} {} {} {} ", nonce[0], nonce[1], nonce[2], nonce[3], revision)
|
||||
}
|
||||
|
||||
fn to_u32(what: &str, v: u64) -> Result<u32> {
|
||||
pub(super) fn to_u32(what: &str, v: u64) -> Result<u32> {
|
||||
u32::try_from(v).map_err(|_| PortableError::Invalid(format!("{what} {v} does not fit the realm's 32-bit column")))
|
||||
}
|
||||
|
||||
fn content_id(what: &str, id: &ContentId, ns: &str, kind: &str) -> Result<u32> {
|
||||
pub(super) fn content_id(what: &str, id: &ContentId, ns: &str, kind: &str) -> Result<u32> {
|
||||
if id.namespace() != ns || id.kind() != kind {
|
||||
return Err(PortableError::Invalid(format!("{what} {id} is not a {ns}:{kind} id and cannot be imported into a {ns} realm")));
|
||||
}
|
||||
to_u32(what, id.id())
|
||||
}
|
||||
|
||||
fn settings_text(values: &[u32]) -> String {
|
||||
pub(super) fn settings_text(values: &[u32]) -> String {
|
||||
values.iter().map(|v| format!("{v} ")).collect()
|
||||
}
|
||||
|
||||
/// `a b c ... ` for the 12 enchantment slots, as the realm stores them.
|
||||
fn enchantment_text(item: &PortableItem) -> String {
|
||||
pub(super) fn enchantment_text(item: &PortableItem) -> String {
|
||||
let mut slots = [[0u32; 3]; 12];
|
||||
for e in &item.enchantments {
|
||||
slots[e.slot as usize] = [e.id, e.duration, e.charges];
|
||||
@@ -203,7 +203,7 @@ fn enchantment_text(item: &PortableItem) -> String {
|
||||
slots.iter().flatten().map(|v| format!("{v} ")).collect()
|
||||
}
|
||||
|
||||
fn charges_text(item: &PortableItem) -> Option<String> {
|
||||
pub(super) fn charges_text(item: &PortableItem) -> Option<String> {
|
||||
if item.charges.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ pub const QUARANTINE_EXTENSION: &str = "coa:unlisted-settings";
|
||||
/// The source the offline importer writes to mark "this character was imported by import N": see `import.rs`.
|
||||
pub const IMPORT_MARKER_SOURCE: &str = "coa.portable.import";
|
||||
|
||||
/// Written next to the marker by an in-place update: the first item guid and pet number the update allocated (recovery needs
|
||||
/// them when the realm's answer was lost).
|
||||
pub const ALLOC_MARKER_SOURCE: &str = "coa.portable.alloc";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Disposition {
|
||||
Carry,
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Phase 4: the round trip. A character leaves canonical storage for a realm, is played there, and what was played comes back.
|
||||
//!
|
||||
//! ```text
|
||||
//! C0 canonical when the character joined the realm (the realm's synced snapshot)
|
||||
//! B0 the realm's own first load/save of it, before any play (captured by `begin_session`)
|
||||
//! B1 the realm now (read by `reconcile_session`)
|
||||
//!
|
||||
//! canonical' = merge3(target = C0, base = B0, ours = B1, Lenient) only B0 -> B1 is progress
|
||||
//! realm' = merge3(target = realm now, base = synced, ours = canonical', Strict) in place, never re-created
|
||||
//! ```
|
||||
//!
|
||||
//! Everything the realm did *by itself* before `B0` (default spells, skills and reputations, honor/title normalisation,
|
||||
//! spells removed by the client data, items it mailed because they did not fit) is in `B0` and therefore never counts as
|
||||
//! progress and never overwrites the canonical character. Everything realm-side that the canonical character does not own
|
||||
//! (items the realm added; items it holds back) is tracked per mapping (`present` / `filtered` / `realm_local`) and
|
||||
//! survives in both directions.
|
||||
//!
|
||||
//! All realm writes of this module are the in-place update of [`super::update`]; reconciling a session reads the realm
|
||||
//! only.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::db::Db;
|
||||
|
||||
use super::super::error::{PortableError, Result};
|
||||
use super::super::ids::{CharacterId, ImportId, PortableItemId, PortablePetId};
|
||||
use super::super::merge::{merge3, ItemOutcome, Mode, PetOutcome};
|
||||
use super::super::model::PortableCharacter;
|
||||
use super::super::store::{BaselineInput, ImportAllocation, ImportState, JournalEntry, JournalKind, PlannedItem, PlannedPet, Store, UpdatePlan};
|
||||
use super::import::{ImportOptions, ImportProblem, Resolution};
|
||||
use super::plan::IMPORT_LOCK;
|
||||
use super::script::{parse_output, Query};
|
||||
use super::sqlenc::Val;
|
||||
use super::update::{build_update, new_content, parse_update_report, UpdateContext, UpdateCounts};
|
||||
use super::{export_character_with_pets, probe, realm_error, ruleset_of};
|
||||
use crate::portable::store::pet_identity;
|
||||
|
||||
fn local_guid(store: &Store, id: CharacterId, server_id: &str) -> Result<(u32, u64)> {
|
||||
let mapping = store
|
||||
.server_mappings(id)?
|
||||
.into_iter()
|
||||
.find(|m| m.server_id == server_id)
|
||||
.ok_or_else(|| PortableError::Invalid(format!("character {id} is not on realm {server_id}")))?;
|
||||
Ok((mapping.local_guid, mapping.last_revision))
|
||||
}
|
||||
|
||||
/// What the realm shows of a mapped character: the model and the id tables the update needs.
|
||||
struct RealmView {
|
||||
exported: super::Exported,
|
||||
items: HashMap<PortableItemId, u32>,
|
||||
pets: HashMap<PortablePetId, u32>,
|
||||
}
|
||||
|
||||
fn read_realm(db: &Db, store: &Store, id: CharacterId, server_id: &str, local_guid: u32) -> Result<RealmView> {
|
||||
let prior_items = store.active_item_lookup(id, server_id)?;
|
||||
let prior_pets = store.active_pet_lookup(id, server_id)?;
|
||||
let exported = export_character_with_pets(db, local_guid, Some(id), &prior_items, &prior_pets)?;
|
||||
let items = exported.observations.iter().map(|o| (o.portable_item_id, o.local_item_guid)).collect();
|
||||
let pets = exported.pet_observations.iter().map(|o| (o.portable_pet_id, o.local_pet_number)).collect();
|
||||
Ok(RealmView { exported, items, pets })
|
||||
}
|
||||
|
||||
// ---- the session baseline -----------------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SessionStart {
|
||||
/// The canonical revision the session joined at.
|
||||
pub c0_revision: u64,
|
||||
/// Items / pets the canonical character owns but the realm did not show at `B0` (filtered by the realm: they stay canonical).
|
||||
pub items_filtered: usize,
|
||||
pub pets_filtered: usize,
|
||||
/// Items / pets the realm showed at `B0` that the canonical character does not own (realm-local: never merged).
|
||||
pub items_realm_local: usize,
|
||||
pub pets_realm_local: usize,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Freeze `B0`: the realm's state of this character after its own first load and save and before any play.
|
||||
///
|
||||
/// Call it **once per session, with the realm's first normalisation done and before the character is played**. The caller
|
||||
/// (the Manager's join flow) owns that ordering; a baseline captured later would treat the progress made so far as the
|
||||
/// realm's own normalisation and lose it.
|
||||
pub fn begin_session(db: &Db, store: &mut Store, id: CharacterId, server_id: &str) -> Result<SessionStart> {
|
||||
let (guid, last_revision) = local_guid(store, id, server_id)?;
|
||||
if let Some(open) = store.open_imports(server_id)?.into_iter().find(|e| e.character_id == id) {
|
||||
return Err(PortableError::ImportInProgress { import_id: open.import_id });
|
||||
}
|
||||
if last_revision != store.character(id)?.revision {
|
||||
return Err(PortableError::StaleRevision { expected: last_revision, current: store.character(id)?.revision });
|
||||
}
|
||||
let view = read_realm(db, store, id, server_id, guid)?;
|
||||
let baseline = store.capture_baseline(id, server_id, BaselineInput { b0: &view.exported.model, items: &view.exported.observations, pets: &view.exported.pet_observations })?;
|
||||
let (c0_items, c0_pets) = (baseline.c0.items.iter().map(|i| i.id).collect::<std::collections::HashSet<_>>(), baseline.c0.pets.iter().map(|p| p.id).collect::<std::collections::HashSet<_>>());
|
||||
Ok(SessionStart {
|
||||
c0_revision: baseline.c0_revision,
|
||||
items_filtered: c0_items.iter().filter(|i| !view.items.contains_key(i)).count(),
|
||||
pets_filtered: c0_pets.iter().filter(|p| !view.pets.contains_key(p)).count(),
|
||||
items_realm_local: view.items.keys().filter(|i| !c0_items.contains(i)).count(),
|
||||
pets_realm_local: view.pets.keys().filter(|p| !c0_pets.contains(p)).count(),
|
||||
warnings: view.exported.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- reconciling a session back into canonical storage -----------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReconcileOutcome {
|
||||
/// The canonical revision after the reconcile (unchanged when the session changed nothing).
|
||||
pub revision: u64,
|
||||
pub new_revision: bool,
|
||||
/// What the session changed, as applied to the canonical character.
|
||||
pub changes: Vec<String>,
|
||||
/// What differs between the canonical character and the realm's `B0` and was deliberately not touched.
|
||||
pub left_alone: Vec<String>,
|
||||
pub items: ItemOutcome,
|
||||
pub pets: PetOutcome,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Bring what was played on the realm back into the canonical character: `merge3(C0, B0, B1)`.
|
||||
///
|
||||
/// Reads the realm only (the character must be offline, as for every export). `close_session = false` keeps the baseline
|
||||
/// open, which makes this a checkpoint; calling it again later applies the *whole* `B0 -> B1'` delta to `C0` again, so
|
||||
/// repeating it never counts progress twice.
|
||||
pub fn reconcile_session(db: &Db, store: &mut Store, id: CharacterId, server_id: &str, close_session: bool, note: Option<&str>) -> Result<ReconcileOutcome> {
|
||||
let baseline = store.open_baseline(id, server_id)?.ok_or(PortableError::NoBaseline)?;
|
||||
let (guid, _) = local_guid(store, id, server_id)?;
|
||||
if let Some(open) = store.open_imports(server_id)?.into_iter().find(|e| e.character_id == id) {
|
||||
return Err(PortableError::ImportInProgress { import_id: open.import_id });
|
||||
}
|
||||
let record = store.character(id)?;
|
||||
if record.revision != baseline.head_revision {
|
||||
return Err(PortableError::StaleRevision { expected: baseline.head_revision, current: record.revision });
|
||||
}
|
||||
let view = read_realm(db, store, id, server_id, guid)?;
|
||||
let merged = merge3(&baseline.c0, &baseline.b0, &view.exported.model, Mode::Lenient)?;
|
||||
let before = record.revision;
|
||||
let revision = store.commit_reconciled(id, server_id, merged.model, &view.exported.observations, &view.exported.pet_observations, note)?;
|
||||
if close_session {
|
||||
store.close_baseline(id, server_id)?;
|
||||
}
|
||||
Ok(ReconcileOutcome { revision, new_revision: revision != before, changes: merged.changes, left_alone: merged.left_alone, items: merged.items, pets: merged.pets, warnings: view.exported.warnings })
|
||||
}
|
||||
|
||||
// ---- updating the realm's character in place ------------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateOutcome {
|
||||
pub import_id: Option<ImportId>,
|
||||
pub from_revision: u64,
|
||||
pub to_revision: u64,
|
||||
/// `false` when the realm was already at the canonical revision: nothing was written.
|
||||
pub updated: bool,
|
||||
pub counts: UpdateCounts,
|
||||
pub changes: Vec<String>,
|
||||
pub left_alone: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
fn update_problems(db: &Db, local_guid: u32, items: &std::collections::BTreeSet<u32>, creatures: &std::collections::BTreeSet<u32>, opts: &ImportOptions) -> Result<Vec<ImportProblem>> {
|
||||
let users = opts.game_server_users.iter().map(|u| Val::text(u.clone()).sql()).collect::<Vec<_>>().join(", ");
|
||||
let count = |name: &str, sql: String| Query { name: name.to_string(), columns: vec!["n"], sql };
|
||||
let ids = |set: &std::collections::BTreeSet<u32>| set.iter().map(u32::to_string).collect::<Vec<_>>().join(", ");
|
||||
let mut queries = vec![
|
||||
count("sessions", format!("SELECT COUNT(*) FROM information_schema.processlist WHERE id <> CONNECTION_ID() AND user IN ({users})")),
|
||||
count("online", "SELECT COUNT(*) FROM acore_characters.characters WHERE online <> 0".into()),
|
||||
count("character", format!("SELECT COUNT(*) FROM acore_characters.characters WHERE guid = {local_guid} AND deleteDate IS NULL")),
|
||||
];
|
||||
if !items.is_empty() {
|
||||
queries.push(Query { name: "item_entries".into(), columns: vec!["entry"], sql: format!("SELECT entry FROM acore_world.item_template WHERE entry IN ({})", ids(items)) });
|
||||
}
|
||||
if !creatures.is_empty() {
|
||||
queries.push(Query { name: "creature_entries".into(), columns: vec!["entry"], sql: format!("SELECT entry FROM acore_world.creature_template WHERE entry IN ({})", ids(creatures)) });
|
||||
}
|
||||
let output = db.query(&super::script::snapshot_script(&queries)).map_err(realm_error)?;
|
||||
let raw = parse_output(&output, &queries)?;
|
||||
let one = |name: &str| -> Result<u64> { raw.section(name)?.iter().next().map(|r| r.u64("n")).transpose().map(|v| v.unwrap_or(0)) };
|
||||
let mut problems = Vec::new();
|
||||
if one("sessions")? > 0 {
|
||||
problems.push(ImportProblem::RealmRunning { sessions: one("sessions")? });
|
||||
}
|
||||
if one("online")? > 0 {
|
||||
problems.push(ImportProblem::OnlineCharacters(one("online")?));
|
||||
}
|
||||
if one("character")? == 0 {
|
||||
problems.push(ImportProblem::CharacterUnavailable(local_guid));
|
||||
}
|
||||
let known = |name: &str| -> Result<std::collections::BTreeSet<u32>> {
|
||||
if !raw.has(name) {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
raw.section(name)?.iter().map(|r| r.u32("entry")).collect()
|
||||
};
|
||||
let missing: Vec<u32> = items.difference(&known("item_entries")?).copied().collect();
|
||||
if !missing.is_empty() {
|
||||
problems.push(ImportProblem::MissingItems(missing));
|
||||
}
|
||||
let missing: Vec<u32> = creatures.difference(&known("creature_entries")?).copied().collect();
|
||||
if !missing.is_empty() {
|
||||
problems.push(ImportProblem::MissingPetCreatures(missing));
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
|
||||
/// Bring the character on `server_id` to the current canonical revision, **in place**: the same local character, its guid,
|
||||
/// position, homebind and everything the model does not carry stay as they are; only the portable subset that differs is
|
||||
/// written, by one transaction, journaled like an import.
|
||||
///
|
||||
/// Refused (nothing written) when a session is open on that realm (reconcile it first), when the realm is running, or when
|
||||
/// the realm and the canonical character changed the same thing differently since they were last synchronised.
|
||||
pub fn update_realm_character(db: &Db, store: &mut Store, id: CharacterId, server_id: &str, opts: &ImportOptions) -> Result<UpdateOutcome> {
|
||||
let (guid, last_revision) = local_guid(store, id, server_id)?;
|
||||
if store.open_baseline(id, server_id)?.is_some() {
|
||||
return Err(PortableError::SessionOpen);
|
||||
}
|
||||
if let Some(open) = store.open_imports(server_id)?.into_iter().find(|e| e.character_id == id) {
|
||||
return Err(PortableError::ImportInProgress { import_id: open.import_id });
|
||||
}
|
||||
let record = store.character(id)?;
|
||||
if record.revision == last_revision {
|
||||
return Ok(UpdateOutcome { import_id: None, from_revision: last_revision, to_revision: last_revision, updated: false, counts: UpdateCounts::default(), changes: vec![], left_alone: vec![], warnings: vec![] });
|
||||
}
|
||||
if record.revision < last_revision {
|
||||
return Err(PortableError::StaleRevision { expected: last_revision, current: record.revision });
|
||||
}
|
||||
let synced = store.synced_model(id, server_id)?.ok_or_else(|| PortableError::Invalid("the realm has no synchronised snapshot to update from".into()))?;
|
||||
let canonical = store.load_current(id)?;
|
||||
let view = read_realm(db, store, id, server_id, guid)?;
|
||||
|
||||
let merged = merge3(&view.exported.model, &synced, &canonical, Mode::Strict)?;
|
||||
if !merged.conflicts.is_empty() {
|
||||
return Err(PortableError::UpdateConflicts(merged.conflicts.iter().map(|c| format!("{}: {}", c.path, c.detail)).collect()));
|
||||
}
|
||||
let schema = probe(db)?;
|
||||
let context = |nonce| UpdateContext {
|
||||
ruleset: ruleset_of(db),
|
||||
local_guid: guid,
|
||||
revision: record.revision,
|
||||
nonce,
|
||||
game_server_users: &opts.game_server_users,
|
||||
probe: &schema,
|
||||
items: &view.items,
|
||||
pets: &view.pets,
|
||||
};
|
||||
// the plan (what is added, what is removed) does not depend on the nonce
|
||||
let draft = build_update(&view.exported.model, &merged.model, &context([0; 4]))?;
|
||||
let (new_items, new_creatures) = new_content(&merged.model, &draft.added_items, &draft.added_pets);
|
||||
let problems = update_problems(db, guid, &new_items, &new_creatures, opts)?;
|
||||
if !problems.is_empty() {
|
||||
return Err(PortableError::ImportRefused(problems));
|
||||
}
|
||||
|
||||
let by_id_item: HashMap<_, _> = merged.model.items.iter().map(|i| (i.id, i)).collect();
|
||||
let by_id_pet: HashMap<_, _> = merged.model.pets.iter().map(|p| (p.id, p)).collect();
|
||||
let plan = UpdatePlan {
|
||||
added_items: draft.added_items.iter().map(|id| PlannedItem { id: *id, entry: by_id_item[id].entry.clone(), identity: super::super::identity::item_identity(&by_id_item[id].entry, by_id_item[id].random_property_id) }).collect(),
|
||||
added_pets: draft.added_pets.iter().map(|id| PlannedPet { id: *id, entry: by_id_pet[id].entry.clone(), identity: pet_identity(&by_id_pet[id].entry, by_id_pet[id].pet_type, by_id_pet[id].created_by_spell) }).collect(),
|
||||
retired_items: draft.removed_items.clone(),
|
||||
retired_pets: draft.removed_pets.clone(),
|
||||
};
|
||||
let ticket = store.begin_update(id, server_id, record.revision, plan)?;
|
||||
let script = match build_update(&view.exported.model, &merged.model, &context(ticket.nonce)) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
store.abort_import(ticket.import_id, &format!("the update could not be built: {e}"))?;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
debug_assert_eq!(script.marker_data, ticket.marker);
|
||||
|
||||
let reported = db.query(&script.script).map_err(realm_error).and_then(|out| parse_update_report(&out));
|
||||
match reported {
|
||||
Ok(((g, item_base, pet_base), true)) if g == guid => {
|
||||
store.finish_import(ticket.import_id, ImportAllocation { local_guid: g, item_base, pet_base })?;
|
||||
}
|
||||
outcome => {
|
||||
let original = outcome.err();
|
||||
match resolve_import_update(db, store, ticket.import_id, opts, false)? {
|
||||
Resolution::Committed(_) => {}
|
||||
Resolution::Aborted => return Err(original.unwrap_or_else(|| PortableError::RealmRead("the realm did not commit the update".into()))),
|
||||
Resolution::Pending(why) => {
|
||||
return Err(PortableError::ImportNeedsAttention { import_id: ticket.import_id, detail: format!("the outcome in the realm is not known yet ({why}); run recovery") })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(UpdateOutcome {
|
||||
import_id: Some(ticket.import_id),
|
||||
from_revision: last_revision,
|
||||
to_revision: record.revision,
|
||||
updated: true,
|
||||
counts: script.counts,
|
||||
changes: merged.changes,
|
||||
left_alone: merged.left_alone,
|
||||
warnings: view.exported.warnings,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- recovery of an interrupted update -------------------------------------------------------------------------------------------
|
||||
|
||||
fn recovery_script(local_guid: u32, wait: u32) -> String {
|
||||
let marker = Val::text(super::policy::IMPORT_MARKER_SOURCE).sql();
|
||||
let alloc = Val::text(super::policy::ALLOC_MARKER_SOURCE).sql();
|
||||
format!(
|
||||
"SELECT '#T:lock';\nSELECT GET_LOCK('{IMPORT_LOCK}', {wait});\n\
|
||||
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;\nSTART TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;\n\
|
||||
SELECT '#T:marker';\nSELECT IFNULL((SELECT data FROM acore_characters.character_settings WHERE guid = {local_guid} AND source = {marker}), 'none');\n\
|
||||
SELECT '#T:alloc';\nSELECT IFNULL((SELECT data FROM acore_characters.character_settings WHERE guid = {local_guid} AND source = {alloc}), 'none');\n\
|
||||
COMMIT;\nDO RELEASE_LOCK('{IMPORT_LOCK}');\n"
|
||||
)
|
||||
}
|
||||
|
||||
/// Decide what became of one unfinished **update** and bring the local records in line with the realm. The marker row the
|
||||
/// update wrote inside its transaction is the proof: present with this update's nonce = committed.
|
||||
pub fn resolve_import_update(db: &Db, store: &mut Store, import_id: ImportId, opts: &ImportOptions, with_grace: bool) -> Result<Resolution> {
|
||||
let entry: JournalEntry = store.import_entry(import_id)?;
|
||||
if entry.kind != JournalKind::Update {
|
||||
return Err(PortableError::Invalid("this journal entry is not an update".into()));
|
||||
}
|
||||
match entry.state {
|
||||
ImportState::Committed => return Ok(Resolution::Committed(entry.allocation.expect("a committed entry has its allocation"))),
|
||||
ImportState::Aborted => return Ok(Resolution::Aborted),
|
||||
ImportState::NeedsAttention => return Err(PortableError::ImportNeedsAttention { import_id, detail: entry.detail.unwrap_or_default() }),
|
||||
ImportState::Prepared => {}
|
||||
}
|
||||
let (guid, _) = local_guid(store, entry.character_id, &entry.server_id)?;
|
||||
let queries = vec![
|
||||
Query { name: "lock".into(), columns: vec!["n"], sql: String::new() },
|
||||
Query { name: "marker".into(), columns: vec!["data"], sql: String::new() },
|
||||
Query { name: "alloc".into(), columns: vec!["data"], sql: String::new() },
|
||||
];
|
||||
let output = db.query(&recovery_script(guid, opts.lock_wait_seconds)).map_err(realm_error)?;
|
||||
let raw = parse_output(&output, &queries)?;
|
||||
let cell = |name: &str| -> Result<String> { Ok(raw.section(name)?.iter().next().ok_or_else(|| PortableError::CorruptSnapshot(format!("no {name} answer")))?.plain("data")?.trim().to_string()) };
|
||||
if raw.section("lock")?.iter().next().ok_or_else(|| PortableError::CorruptSnapshot("no lock answer".into()))?.u64("n")? != 1 {
|
||||
return Ok(Resolution::Pending("an import is still running in the realm".into()));
|
||||
}
|
||||
if cell("marker")? != entry.marker.trim() {
|
||||
if with_grace && age_seconds(&entry.created_at) < opts.recovery_grace.as_secs() as i64 {
|
||||
return Ok(Resolution::Pending("the update is too recent to be called lost".into()));
|
||||
}
|
||||
store.abort_import(import_id, "the realm has no trace of this update: it never committed")?;
|
||||
return Ok(Resolution::Aborted);
|
||||
}
|
||||
let alloc_text = cell("alloc")?;
|
||||
let words: Vec<&str> = alloc_text.split_whitespace().collect();
|
||||
let (item_base, pet_base) = match words.as_slice() {
|
||||
[i, p] => (i.parse::<u32>().ok(), p.parse::<u32>().ok()),
|
||||
_ => (None, None),
|
||||
};
|
||||
let (Some(item_base), Some(pet_base)) = (item_base, pet_base) else {
|
||||
let detail = format!("the update's marker is in the realm but its allocation row is unreadable ({alloc_text:?})");
|
||||
store.flag_import(import_id, &detail)?;
|
||||
return Err(PortableError::ImportNeedsAttention { import_id, detail });
|
||||
};
|
||||
// the items and pets the update allocated must be there, contiguous from the recorded bases
|
||||
let present = |sql: String| -> Result<u64> {
|
||||
db.query(&sql).map_err(realm_error)?.lines().next().unwrap_or("").trim().parse().map_err(|_| PortableError::CorruptSnapshot("unexpected recovery answer".into()))
|
||||
};
|
||||
let (n_items, n_pets) = (entry.items.len() as u64, entry.pets.len() as u64);
|
||||
let items_found = if n_items == 0 { 0 } else { present(format!("SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = {guid} AND guid >= {item_base} AND guid < {item_base} + {n_items}"))? };
|
||||
let pets_found = if n_pets == 0 { 0 } else { present(format!("SELECT COUNT(*) FROM acore_characters.character_pet WHERE owner = {guid} AND id >= {pet_base} AND id < {pet_base} + {n_pets}"))? };
|
||||
if items_found != n_items || pets_found != n_pets {
|
||||
let detail = format!("the realm carries this update's marker but holds {items_found} of {n_items} new items and {pets_found} of {n_pets} new pets");
|
||||
store.flag_import(import_id, &detail)?;
|
||||
return Err(PortableError::ImportNeedsAttention { import_id, detail });
|
||||
}
|
||||
let alloc = ImportAllocation { local_guid: guid, item_base, pet_base };
|
||||
store.finish_import(import_id, alloc)?;
|
||||
Ok(Resolution::Committed(alloc))
|
||||
}
|
||||
|
||||
fn age_seconds(created_at: &str) -> i64 {
|
||||
chrono::DateTime::parse_from_rfc3339(created_at).map(|t| (chrono::Utc::now() - t.with_timezone(&chrono::Utc)).num_seconds()).unwrap_or(i64::MAX)
|
||||
}
|
||||
|
||||
/// Re-exported for callers that only hold a merged result and want the counts without running anything.
|
||||
pub fn summarize(merged: &PortableCharacter, current: &PortableCharacter) -> Result<UpdateCounts> {
|
||||
let items: HashMap<PortableItemId, u32> = current.items.iter().enumerate().map(|(i, it)| (it.id, i as u32 + 1)).collect();
|
||||
let pets: HashMap<PortablePetId, u32> = current.pets.iter().enumerate().map(|(i, p)| (p.id, i as u32 + 1)).collect();
|
||||
let users = ["acore".to_string()];
|
||||
let schema = super::script::SchemaProbe::default();
|
||||
Ok(build_update(current, merged, &UpdateContext { ruleset: current.ruleset, local_guid: 1, revision: 1, nonce: [0; 4], game_server_users: &users, probe: &schema, items: &items, pets: &pets })?.counts)
|
||||
}
|
||||
@@ -101,10 +101,90 @@ impl Insert {
|
||||
}
|
||||
}
|
||||
|
||||
fn condition(filter: &[(&'static str, Val)]) -> String {
|
||||
filter.iter().map(|(c, v)| format!("`{c}` = {}", v.sql())).collect::<Vec<_>>().join(" AND ")
|
||||
}
|
||||
|
||||
/// One `UPDATE` with a fixed table, fixed columns and a fixed key condition (`column = value AND ...`).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Update {
|
||||
table: &'static str,
|
||||
filter: Vec<(&'static str, Val)>,
|
||||
sets: Vec<(&'static str, Val)>,
|
||||
}
|
||||
|
||||
impl Update {
|
||||
pub fn new(table: &'static str, filter: Vec<(&'static str, Val)>) -> Self {
|
||||
Self { table, filter, sets: Vec::new() }
|
||||
}
|
||||
pub fn set(&mut self, column: &'static str, value: Val) {
|
||||
self.sets.push((column, value));
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.sets.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sets.is_empty()
|
||||
}
|
||||
/// `None` when nothing is to be set.
|
||||
pub fn sql(&self) -> Option<String> {
|
||||
if self.sets.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sets = self.sets.iter().map(|(c, v)| format!("`{c}` = {}", v.sql())).collect::<Vec<_>>().join(", ");
|
||||
Some(format!("UPDATE acore_characters.`{}` SET {sets} WHERE {};", self.table, condition(&self.filter)))
|
||||
}
|
||||
}
|
||||
|
||||
/// One `DELETE` with a fixed table, a fixed key condition and optionally one `column IN (values)` list.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Delete {
|
||||
table: &'static str,
|
||||
filter: Vec<(&'static str, Val)>,
|
||||
any_of: Option<(&'static str, Vec<Val>)>,
|
||||
}
|
||||
|
||||
impl Delete {
|
||||
pub fn new(table: &'static str, filter: Vec<(&'static str, Val)>) -> Self {
|
||||
Self { table, filter, any_of: None }
|
||||
}
|
||||
pub fn any_of(table: &'static str, filter: Vec<(&'static str, Val)>, column: &'static str, values: Vec<Val>) -> Self {
|
||||
Self { table, filter, any_of: Some((column, values)) }
|
||||
}
|
||||
pub fn sql(&self) -> Option<String> {
|
||||
let mut parts = vec![condition(&self.filter)];
|
||||
if let Some((column, values)) = &self.any_of {
|
||||
if values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
parts.push(format!("`{column}` IN ({})", values.iter().map(Val::sql).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
parts.retain(|p| !p.is_empty());
|
||||
assert!(!parts.is_empty(), "internal: a DELETE without a condition");
|
||||
Some(format!("DELETE FROM acore_characters.`{}` WHERE {};", self.table, parts.join(" AND ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn update_and_delete_are_fixed_scaffolds_around_encoded_values() {
|
||||
let mut u = Update::new("characters", vec![("guid", Val::Expr("@char"))]);
|
||||
assert!(u.sql().is_none());
|
||||
u.set("name", Val::text("it's \"a\" name'; DROP"));
|
||||
u.set("money", Val::u(5u32));
|
||||
let sql = u.sql().unwrap();
|
||||
assert!(sql.starts_with("UPDATE acore_characters.`characters` SET `name` = _utf8mb4 0x"));
|
||||
assert!(sql.ends_with("WHERE `guid` = @char;"));
|
||||
assert_eq!(sql.matches('\'').count(), 0, "{sql}");
|
||||
let d = Delete::any_of("character_spell", vec![("guid", Val::Expr("@char"))], "spell", vec![Val::u(1u32), Val::u(2u32)]);
|
||||
assert_eq!(d.sql().unwrap(), "DELETE FROM acore_characters.`character_spell` WHERE `guid` = @char AND `spell` IN (1, 2);");
|
||||
assert!(Delete::any_of("t", vec![("guid", Val::Expr("@char"))], "x", vec![]).sql().is_none(), "an empty list deletes nothing");
|
||||
assert_eq!(Delete::new("t", vec![("a", Val::u(1u8)), ("b", Val::u(2u8))]).sql().unwrap(), "DELETE FROM acore_characters.`t` WHERE `a` = 1 AND `b` = 2;");
|
||||
}
|
||||
|
||||
/// Characters that must never appear in generated SQL because of untrusted data.
|
||||
const HOSTILE: &[char] = &['\'', '"', '\\', '\0', '\n', '\r', '\t', ';', '`', '-', '#', '/', '*', '%', ' '];
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ fn raw(guid: u32) -> RawExport {
|
||||
}
|
||||
|
||||
fn export_as(guid: u32, ruleset: Ruleset, prior: &HashMap<u32, (PortableItemId, String)>) -> Result<Exported> {
|
||||
build(&raw(guid), &ExportRequest { ruleset, local_guid: guid, character_id: None, prior_items: prior })
|
||||
build(&raw(guid), &ExportRequest { ruleset, local_guid: guid, character_id: None, prior_items: prior, prior_pets: &HashMap::new() })
|
||||
}
|
||||
|
||||
fn export(guid: u32) -> Exported {
|
||||
@@ -313,7 +313,7 @@ fn items_keep_their_portable_ids_but_a_recycled_guid_gets_a_new_one() {
|
||||
#[test]
|
||||
fn a_reexport_keeps_the_portable_character_id() {
|
||||
let id = CharacterId::new();
|
||||
let e = build(&raw(1002), &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: Some(id), prior_items: &HashMap::new() }).unwrap();
|
||||
let e = build(&raw(1002), &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: Some(id), prior_items: &HashMap::new(), prior_pets: &HashMap::new() }).unwrap();
|
||||
assert_eq!(e.model.character_id, id);
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ fn windows_line_endings_do_not_matter() {
|
||||
let crlf = answer(1002).replace("\r\n", "\n").replace('\n', "\r\n");
|
||||
let queries = script::queries(1002, &probe()).unwrap();
|
||||
let parsed = script::parse_output(&crlf, &queries).unwrap();
|
||||
let e = build(&parsed, &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: None, prior_items: &HashMap::new() }).unwrap();
|
||||
let e = build(&parsed, &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: None, prior_items: &HashMap::new(), prior_pets: &HashMap::new() }).unwrap();
|
||||
assert_eq!((e.model.items.len(), e.model.identity.name.as_str()), (60, "Geared"));
|
||||
}
|
||||
|
||||
@@ -379,7 +379,7 @@ fn damaged_answers_never_panic() {
|
||||
bytes[at] = [b'Z', b'\t', b'\n', b'x', b'-', b'9', b'0', b'~'][next() as usize % 8];
|
||||
}
|
||||
let Ok(text) = String::from_utf8(bytes) else { continue };
|
||||
match script::parse_output(&text, &queries).and_then(|raw| build(&raw, &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: None, prior_items: &HashMap::new() })) {
|
||||
match script::parse_output(&text, &queries).and_then(|raw| build(&raw, &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: None, prior_items: &HashMap::new(), prior_pets: &HashMap::new() })) {
|
||||
Ok(e) => {
|
||||
e.model.validate().unwrap();
|
||||
oks += 1;
|
||||
@@ -428,7 +428,7 @@ fn make_portable_registers_everything_in_one_step_and_refuses_twice() {
|
||||
|
||||
// a re-export with the store's mappings keeps every item id, so the next revision is a plain update
|
||||
let lookup = store.active_item_lookup(made.character_id, "realm-a").unwrap();
|
||||
let re = build(&raw(1002), &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: Some(made.character_id), prior_items: &lookup }).unwrap();
|
||||
let re = build(&raw(1002), &ExportRequest { ruleset: Ruleset::Coa, local_guid: 1002, character_id: Some(made.character_id), prior_items: &lookup, prior_pets: &HashMap::new() }).unwrap();
|
||||
assert_eq!(re.model, expected_model);
|
||||
let revision = store.commit_snapshot(made.character_id, 1, re.model, "realm-a", Some("re-export")).unwrap();
|
||||
let report = store.reconcile_item_mappings(made.character_id, "realm-a", revision, &re.observations).unwrap();
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
//! Phase 4: bringing an **existing** realm character to a newer canonical state, in place.
|
||||
//!
|
||||
//! `build_update(current, merged, ctx)` turns the difference between what the realm holds *now* (`current`, a fresh export)
|
||||
//! and what it should hold afterwards (`merged`, the three-way merge of the realm's state, the snapshot it was last synced
|
||||
//! with, and the new canonical) into one fixed SQL script that runs in one MySQL transaction.
|
||||
//!
|
||||
//! What this never does:
|
||||
//! * delete or re-create the character: the local guid, the account, `online`, `logout_time`, the **position** (`map`,
|
||||
//! `zone`, `position_*`, `orientation`, ...) and the **homebind** are not touched;
|
||||
//! * write a column that did not change: the script is a *delta* of the portable subset, so everything the model does not
|
||||
//! carry (world-local data) stays exactly as the realm has it;
|
||||
//! * touch an item, pet or row that is not in the model: items and pets are addressed by their mapped local ids and only
|
||||
//! rows the diff names are changed.
|
||||
//!
|
||||
//! Layout: lock -> guards (realm stopped, the character is what was read) -> allocation -> deletes -> updates -> inserts ->
|
||||
//! assertions -> marker -> `COMMIT` -> report. Untrusted text and blobs are hex literals from the typed encoder, as in the
|
||||
//! importer.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
|
||||
use super::super::error::{PortableError, Result};
|
||||
use super::super::ids::{PortableItemId, PortablePetId};
|
||||
use super::super::model::*;
|
||||
use super::plan::{charges_text, content_id, enchantment_text, settings_text, FAIL, IMPORT_LOCK, ITEM_KEYED, PET_KEYED};
|
||||
use super::policy::{classify_setting, Disposition, ALLOC_MARKER_SOURCE, IMPORT_MARKER_SOURCE};
|
||||
use super::script::SchemaProbe;
|
||||
use super::sqlenc::{Delete, Insert, Update, Val};
|
||||
|
||||
pub struct UpdateContext<'a> {
|
||||
pub ruleset: Ruleset,
|
||||
pub local_guid: u32,
|
||||
/// The canonical revision the realm is brought to.
|
||||
pub revision: u64,
|
||||
pub nonce: [u32; 4],
|
||||
pub game_server_users: &'a [String],
|
||||
pub probe: &'a SchemaProbe,
|
||||
/// The items the realm holds now (portable id -> local guid): what `current` was exported from.
|
||||
pub items: &'a HashMap<PortableItemId, u32>,
|
||||
pub pets: &'a HashMap<PortablePetId, u32>,
|
||||
}
|
||||
|
||||
/// What the script does, in numbers (and as the plan the journal records).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct UpdateCounts {
|
||||
pub character_columns: usize,
|
||||
pub items_added: usize,
|
||||
pub items_removed: usize,
|
||||
pub items_changed: usize,
|
||||
pub items_moved: usize,
|
||||
pub pets_added: usize,
|
||||
pub pets_removed: usize,
|
||||
pub pets_changed: usize,
|
||||
/// Rows inserted or deleted in keyed tables (spells, skills, reputation, quests, actions, glyphs, settings, macros).
|
||||
pub keyed_rows: usize,
|
||||
}
|
||||
|
||||
impl UpdateCounts {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
*self == UpdateCounts::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UpdateScript {
|
||||
pub script: String,
|
||||
/// Item `i` of this list is realm guid `@item_base + i`.
|
||||
pub added_items: Vec<PortableItemId>,
|
||||
pub added_pets: Vec<PortablePetId>,
|
||||
pub removed_items: Vec<PortableItemId>,
|
||||
pub removed_pets: Vec<PortablePetId>,
|
||||
pub counts: UpdateCounts,
|
||||
pub marker_data: String,
|
||||
}
|
||||
|
||||
fn diff<K: Ord + Clone, V: PartialEq + Clone>(current: &BTreeMap<K, V>, new: &BTreeMap<K, V>) -> (Vec<K>, Vec<(K, V)>) {
|
||||
let (mut delete, mut insert) = (Vec::new(), Vec::new());
|
||||
for (k, v) in current {
|
||||
match new.get(k) {
|
||||
None => delete.push(k.clone()),
|
||||
Some(n) if n != v => {
|
||||
delete.push(k.clone());
|
||||
insert.push((k.clone(), n.clone()));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
for (k, n) in new {
|
||||
if !current.contains_key(k) {
|
||||
insert.push((k.clone(), n.clone()));
|
||||
}
|
||||
}
|
||||
(delete, insert)
|
||||
}
|
||||
|
||||
/// Inventory rows of `@char` whose item instance does not exist.
|
||||
const ORPHAN_ITEMS: &str = "(SELECT COUNT(*) FROM acore_characters.character_inventory ci LEFT JOIN acore_characters.item_instance ii ON ii.guid = ci.item AND ii.owner_guid = @char WHERE ci.guid = @char AND ii.guid IS NULL)";
|
||||
/// Inventory rows of `@char` that sit in a bag the character does not have.
|
||||
const BAGLESS_ITEMS: &str = "(SELECT COUNT(*) FROM acore_characters.character_inventory ci LEFT JOIN acore_characters.character_inventory b ON b.item = ci.bag AND b.guid = @char WHERE ci.guid = @char AND ci.bag <> 0 AND b.item IS NULL)";
|
||||
|
||||
fn max_of(probe: &SchemaProbe, list: &[(&str, &str)]) -> String {
|
||||
let parts: Vec<String> = list.iter().filter(|(t, _)| probe.has(t)).map(|(t, c)| format!("IFNULL((SELECT MAX(`{c}`) FROM acore_characters.`{t}`), 0)")).collect();
|
||||
format!("GREATEST(0, {})", parts.join(", "))
|
||||
}
|
||||
|
||||
/// Statements in the order they must run: every delete, then every update, then every insert.
|
||||
#[derive(Default)]
|
||||
struct Statements {
|
||||
guards: Vec<String>,
|
||||
deletes: Vec<String>,
|
||||
updates: Vec<String>,
|
||||
inserts: Vec<String>,
|
||||
}
|
||||
|
||||
impl Statements {
|
||||
fn delete(&mut self, d: Delete) {
|
||||
self.deletes.extend(d.sql());
|
||||
}
|
||||
fn update(&mut self, u: Update) {
|
||||
self.updates.extend(u.sql());
|
||||
}
|
||||
fn insert(&mut self, i: Insert) {
|
||||
self.inserts.extend(i.sql());
|
||||
}
|
||||
}
|
||||
|
||||
const COSMETIC_KEEP: [(u32, &str); 4] = [
|
||||
(0, "(`playerFlags` & ~3072)"),
|
||||
(0x400, "((`playerFlags` & ~3072) | 1024)"),
|
||||
(0x800, "((`playerFlags` & ~3072) | 2048)"),
|
||||
(0xC00, "((`playerFlags` & ~3072) | 3072)"),
|
||||
];
|
||||
|
||||
fn cannot(what: &str) -> PortableError {
|
||||
PortableError::Invalid(format!("{what} cannot be changed in place; the character would have to be imported again"))
|
||||
}
|
||||
|
||||
pub fn build_update(current: &PortableCharacter, merged: &PortableCharacter, ctx: &UpdateContext<'_>) -> Result<UpdateScript> {
|
||||
merged.validate()?;
|
||||
if current.ruleset != ctx.ruleset || merged.ruleset != ctx.ruleset {
|
||||
return Err(PortableError::WrongRealm { expected: merged.ruleset.to_string(), found: ctx.ruleset.to_string() });
|
||||
}
|
||||
if ctx.game_server_users.is_empty() {
|
||||
return Err(PortableError::Invalid("internal: no game server user is named, the realm cannot be proven stopped".into()));
|
||||
}
|
||||
let ns = ctx.ruleset.as_str();
|
||||
let (ci, mi) = (¤t.identity, &merged.identity);
|
||||
if ci.race != mi.race || ci.class != mi.class || ci.gender != mi.gender {
|
||||
return Err(cannot("race, class and gender"));
|
||||
}
|
||||
|
||||
let mut st = Statements::default();
|
||||
let mut counts = UpdateCounts::default();
|
||||
let char_key = || vec![("guid", Val::Expr("@char"))];
|
||||
|
||||
// ---- the characters row: only the columns that differ ---------------------------------------------------------
|
||||
let mut row = Update::new("characters", char_key());
|
||||
if ci.name != mi.name {
|
||||
row.set("name", Val::text(mi.name.clone()));
|
||||
let name = Val::text(mi.name.clone()).sql();
|
||||
let collate = format!("name COLLATE utf8mb4_unicode_ci = {name} COLLATE utf8mb4_unicode_ci");
|
||||
st.guards.push(format!("DO IF(NOT EXISTS(SELECT 1 FROM acore_characters.characters WHERE guid <> @char AND {collate}), 1, {FAIL});"));
|
||||
if ctx.probe.has("reserved_name") {
|
||||
st.guards.push(format!("DO IF(NOT EXISTS(SELECT 1 FROM acore_characters.reserved_name WHERE {collate}), 1, {FAIL});"));
|
||||
}
|
||||
}
|
||||
let (a, b) = (&ci.appearance, &mi.appearance);
|
||||
for (column, old, new) in [("skin", a.skin, b.skin), ("face", a.face, b.face), ("hairStyle", a.hair_style, b.hair_style), ("hairColor", a.hair_color, b.hair_color), ("facialStyle", a.facial_style, b.facial_style)] {
|
||||
if old != new {
|
||||
row.set(column, Val::u(new));
|
||||
}
|
||||
}
|
||||
if ci.cosmetic_flags != mi.cosmetic_flags {
|
||||
let expr = COSMETIC_KEEP.iter().find(|(v, _)| *v == mi.cosmetic_flags).map(|(_, e)| *e).ok_or_else(|| PortableError::Invalid(format!("cosmetic flags {:#x} are not carried", mi.cosmetic_flags)))?;
|
||||
row.set("playerFlags", Val::Expr(expr));
|
||||
}
|
||||
let (p, q) = (¤t.progression, &merged.progression);
|
||||
let (ph, qh) = (&p.honor, &q.honor);
|
||||
macro_rules! num {
|
||||
($($col:literal: $old:expr => $new:expr),* $(,)?) => { $( if $old != $new { row.set($col, Val::u($new)); } )* };
|
||||
}
|
||||
num! {
|
||||
"level": p.level => q.level, "xp": p.xp => q.xp, "money": p.money => q.money,
|
||||
"arenaPoints": ph.arena_points => qh.arena_points, "totalHonorPoints": ph.total_honor => qh.total_honor,
|
||||
"todayHonorPoints": ph.today_honor => qh.today_honor, "yesterdayHonorPoints": ph.yesterday_honor => qh.yesterday_honor,
|
||||
"totalKills": ph.total_kills => qh.total_kills, "todayKills": ph.today_kills => qh.today_kills, "yesterdayKills": ph.yesterday_kills => qh.yesterday_kills,
|
||||
"knownCurrencies": p.known_currencies => q.known_currencies, "chosenTitle": p.chosen_title => q.chosen_title,
|
||||
"bankSlots": p.bank_slots => q.bank_slots, "stable_slots": p.stable_slots => q.stable_slots,
|
||||
"watchedFaction": p.watched_faction => q.watched_faction, "actionBars": p.action_bars_mask => q.action_bars_mask,
|
||||
"talentGroupsCount": current.build.talent_groups_count => merged.build.talent_groups_count,
|
||||
"activeTalentGroup": current.build.active_talent_group => merged.build.active_talent_group,
|
||||
"resettalents_cost": current.build.reset_talents_cost => merged.build.reset_talents_cost,
|
||||
}
|
||||
for (column, old, new) in [("knownTitles", &p.known_titles, &q.known_titles), ("exploredZones", &p.explored_zones, &q.explored_zones), ("taximask", &p.taxi_mask, &q.taxi_mask)] {
|
||||
if old != new {
|
||||
row.set(column, Val::text(new.clone()));
|
||||
}
|
||||
}
|
||||
if current.build.extra_bonus_talent_count != merged.build.extra_bonus_talent_count {
|
||||
row.set("extraBonusTalentCount", Val::i(merged.build.extra_bonus_talent_count));
|
||||
}
|
||||
counts.character_columns = row.len();
|
||||
st.update(row);
|
||||
|
||||
// ---- keyed tables -----------------------------------------------------------------------------------------------
|
||||
let key_char = |extra: Vec<(&'static str, Val)>| -> Vec<(&'static str, Val)> {
|
||||
let mut k = char_key();
|
||||
k.extend(extra);
|
||||
k
|
||||
};
|
||||
|
||||
// spells
|
||||
let spells = |m: &PortableCharacter| -> BTreeMap<u32, u8> { m.build.spells.iter().copied().collect() };
|
||||
let (del, ins) = diff(&spells(current), &spells(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_spell", char_key(), "spell", del.iter().map(|s| Val::u(*s)).collect()));
|
||||
let mut t = Insert::new("character_spell", &["guid", "spell", "specMask"]);
|
||||
for (spell, mask) in ins {
|
||||
t.row(vec![Val::Expr("@char"), Val::u(spell), Val::u(mask)])?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// skills
|
||||
let skills = |m: &PortableCharacter| -> BTreeMap<u32, (u16, u16)> { m.build.skills.iter().map(|s| (s.skill, (s.value, s.max))).collect() };
|
||||
let (del, ins) = diff(&skills(current), &skills(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_skills", char_key(), "skill", del.iter().map(|s| Val::u(*s)).collect()));
|
||||
let mut t = Insert::new("character_skills", &["guid", "skill", "value", "max"]);
|
||||
for (skill, (value, max)) in ins {
|
||||
t.row(vec![Val::Expr("@char"), Val::u(skill), Val::u(value), Val::u(max)])?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// glyphs
|
||||
let glyphs = |m: &PortableCharacter| -> BTreeMap<u8, [u16; 6]> { m.build.glyphs.iter().map(|g| (g.talent_group, g.glyphs)).collect() };
|
||||
let (del, ins) = diff(&glyphs(current), &glyphs(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_glyphs", char_key(), "talentGroup", del.iter().map(|g| Val::u(*g)).collect()));
|
||||
let mut t = Insert::new("character_glyphs", &["guid", "talentGroup", "glyph1", "glyph2", "glyph3", "glyph4", "glyph5", "glyph6"]);
|
||||
for (group, set) in ins {
|
||||
let mut r = vec![Val::Expr("@char"), Val::u(group)];
|
||||
r.extend(set.iter().map(|v| Val::u(*v)));
|
||||
t.row(r)?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// reputation
|
||||
let rep = |m: &PortableCharacter| -> BTreeMap<u32, (i32, u32)> { m.reputation.iter().map(|r| (r.faction, (r.standing, r.flags))).collect() };
|
||||
let (del, ins) = diff(&rep(current), &rep(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_reputation", char_key(), "faction", del.iter().map(|f| Val::u(*f)).collect()));
|
||||
let mut t = Insert::new("character_reputation", &["guid", "faction", "standing", "flags"]);
|
||||
for (faction, (standing, flags)) in ins {
|
||||
t.row(vec![Val::Expr("@char"), Val::u(faction), Val::i(standing), Val::u(flags)])?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// quests in progress, quests rewarded
|
||||
let quests = |m: &PortableCharacter| -> BTreeMap<u32, ActiveQuest> { m.quests.active.iter().map(|q| (q.quest, q.clone())).collect() };
|
||||
let (del, ins) = diff(&quests(current), &quests(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_queststatus", char_key(), "quest", del.iter().map(|q| Val::u(*q)).collect()));
|
||||
let mut t = Insert::new(
|
||||
"character_queststatus",
|
||||
&["guid", "quest", "status", "explored", "timer", "mobcount1", "mobcount2", "mobcount3", "mobcount4", "itemcount1", "itemcount2", "itemcount3", "itemcount4", "itemcount5", "itemcount6", "playercount"],
|
||||
);
|
||||
for (_, q) in ins {
|
||||
let mut r = vec![Val::Expr("@char"), Val::u(q.quest), Val::u(q.status), Val::u(q.explored as u8), Val::u(q.timer)];
|
||||
r.extend(q.mob_counts.iter().map(|v| Val::u(*v)));
|
||||
r.extend(q.item_counts.iter().map(|v| Val::u(*v)));
|
||||
r.push(Val::u(q.player_count));
|
||||
t.row(r)?;
|
||||
}
|
||||
st.insert(t);
|
||||
let rewarded = |m: &PortableCharacter| -> BTreeMap<u32, ()> { m.quests.rewarded.iter().map(|q| (*q, ())).collect() };
|
||||
let (del, ins) = diff(&rewarded(current), &rewarded(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_queststatus_rewarded", char_key(), "quest", del.iter().map(|q| Val::u(*q)).collect()));
|
||||
let mut t = Insert::new("character_queststatus_rewarded", &["guid", "quest", "active"]);
|
||||
for (quest, _) in ins {
|
||||
t.row(vec![Val::Expr("@char"), Val::u(quest), Val::u(1u8)])?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// action buttons (key: spec + button)
|
||||
let actions = |m: &PortableCharacter| -> BTreeMap<(u8, u8), (u32, u8)> { m.actions.iter().map(|a| ((a.spec, a.button), (a.action, a.kind))).collect() };
|
||||
let (del, ins) = diff(&actions(current), &actions(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
for (spec, button) in del {
|
||||
st.delete(Delete::new("character_action", key_char(vec![("spec", Val::u(spec)), ("button", Val::u(button))])));
|
||||
}
|
||||
let mut t = Insert::new("character_action", &["guid", "spec", "button", "action", "type"]);
|
||||
for ((spec, button), (action, kind)) in ins {
|
||||
t.row(vec![Val::Expr("@char"), Val::u(spec), Val::u(button), Val::u(action), Val::u(kind)])?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// settings: only the strictly carried keys are ever written
|
||||
let carried = |m: &PortableCharacter| -> BTreeMap<String, Vec<u32>> { m.settings.iter().filter(|(k, _)| classify_setting(k, m.ruleset) == Disposition::Carry).map(|(k, v)| (k.clone(), v.clone())).collect() };
|
||||
let (del, ins) = diff(&carried(current), &carried(merged));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("character_settings", char_key(), "source", del.iter().map(|s| Val::text(s.clone())).collect()));
|
||||
let mut t = Insert::new("character_settings", &["guid", "source", "data"]);
|
||||
for (source, values) in ins {
|
||||
t.row(vec![Val::Expr("@char"), Val::text(source), Val::text(settings_text(&values))])?;
|
||||
}
|
||||
st.insert(t);
|
||||
|
||||
// the macros blob (account data type 5)
|
||||
if current.client_data.get(&5) != merged.client_data.get(&5) {
|
||||
counts.keyed_rows += 1;
|
||||
st.delete(Delete::new("character_account_data", key_char(vec![("type", Val::u(5u8))])));
|
||||
if let Some(blob) = merged.client_data.get(&5) {
|
||||
let mut t = Insert::new("character_account_data", &["guid", "type", "time", "data"]);
|
||||
t.row(vec![Val::Expr("@char"), Val::u(5u8), Val::u(blob.time), Val::Bytes(blob.data.0.clone())])?;
|
||||
st.insert(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- items ------------------------------------------------------------------------------------------------------
|
||||
let cur_items: HashMap<PortableItemId, &PortableItem> = current.items.iter().map(|i| (i.id, i)).collect();
|
||||
let new_items: HashMap<PortableItemId, &PortableItem> = merged.items.iter().map(|i| (i.id, i)).collect();
|
||||
for id in cur_items.keys() {
|
||||
if !ctx.items.contains_key(id) {
|
||||
return Err(PortableError::Invalid(format!("internal: item {id} of the realm has no local guid")));
|
||||
}
|
||||
}
|
||||
let removed_items: Vec<PortableItemId> = current.items.iter().map(|i| i.id).filter(|id| !new_items.contains_key(id)).collect();
|
||||
let added_items: Vec<PortableItemId> = merged.items.iter().map(|i| i.id).filter(|id| !cur_items.contains_key(id)).collect();
|
||||
let added_index: HashMap<PortableItemId, u32> = added_items.iter().enumerate().map(|(i, id)| (*id, i as u32)).collect();
|
||||
let local = |id: &PortableItemId| -> Result<Val> {
|
||||
match (ctx.items.get(id), added_index.get(id)) {
|
||||
(Some(g), _) if cur_items.contains_key(id) => Ok(Val::u(*g)),
|
||||
(_, Some(i)) => Ok(Val::ItemRef(*i)),
|
||||
_ => Err(PortableError::Invalid(format!("an item names the container {id}, which the character does not have"))),
|
||||
}
|
||||
};
|
||||
let bag_of = |item: &PortableItem| -> Result<Val> { item.container.as_ref().map_or(Ok(Val::u(0u8)), local) };
|
||||
|
||||
let mut moved_inventory: Vec<PortableItemId> = Vec::new();
|
||||
let mut regift: Vec<PortableItemId> = Vec::new();
|
||||
for item in &merged.items {
|
||||
let Some(old) = cur_items.get(&item.id) else { continue };
|
||||
let guid = *ctx.items.get(&item.id).expect("checked above");
|
||||
if **old == *item {
|
||||
continue;
|
||||
}
|
||||
counts.items_changed += 1;
|
||||
let mut u = Update::new("item_instance", vec![("guid", Val::u(guid)), ("owner_guid", Val::Expr("@char"))]);
|
||||
if old.entry != item.entry {
|
||||
u.set("itemEntry", Val::u(content_id("item", &item.entry, ns, "item")?));
|
||||
}
|
||||
if old.count != item.count {
|
||||
u.set("count", Val::u(item.count));
|
||||
}
|
||||
if old.duration != item.duration {
|
||||
u.set("duration", Val::i(item.duration));
|
||||
}
|
||||
if old.charges != item.charges {
|
||||
u.set("charges", Val::opt_text(charges_text(item).as_deref()));
|
||||
}
|
||||
if old.flags != item.flags {
|
||||
u.set("flags", Val::u(item.flags));
|
||||
}
|
||||
if old.enchantments != item.enchantments {
|
||||
u.set("enchantments", Val::text(enchantment_text(item)));
|
||||
}
|
||||
if old.random_property_id != item.random_property_id {
|
||||
u.set("randomPropertyId", Val::i(item.random_property_id));
|
||||
}
|
||||
if old.durability != item.durability {
|
||||
u.set("durability", Val::u(item.durability));
|
||||
}
|
||||
if old.played_time != item.played_time {
|
||||
u.set("playedTime", Val::u(item.played_time));
|
||||
}
|
||||
if old.text != item.text {
|
||||
u.set("text", Val::opt_text(item.text.as_deref()));
|
||||
}
|
||||
st.update(u);
|
||||
if old.container != item.container || old.slot != item.slot {
|
||||
moved_inventory.push(item.id);
|
||||
counts.items_moved += 1;
|
||||
}
|
||||
if old.gift != item.gift {
|
||||
regift.push(item.id);
|
||||
}
|
||||
}
|
||||
counts.items_removed = removed_items.len();
|
||||
counts.items_added = added_items.len();
|
||||
|
||||
// inventory rows of removed and moved items go first (the places they free may be taken by others), then come back
|
||||
let gone: Vec<Val> = removed_items.iter().chain(&moved_inventory).map(|id| ctx.items.get(id).map(|g| Val::u(*g)).expect("mapped")).collect();
|
||||
st.delete(Delete::any_of("character_inventory", char_key(), "item", gone));
|
||||
if !removed_items.is_empty() {
|
||||
let guids = |ids: &[PortableItemId]| -> Vec<Val> { ids.iter().map(|id| Val::u(*ctx.items.get(id).expect("mapped"))).collect() };
|
||||
st.delete(Delete::any_of("item_instance", vec![("owner_guid", Val::Expr("@char"))], "guid", guids(&removed_items)));
|
||||
st.delete(Delete::any_of("character_gifts", char_key(), "item_guid", guids(&removed_items)));
|
||||
if ctx.probe.has("item_refund_instance") {
|
||||
st.delete(Delete::any_of("item_refund_instance", vec![("player_guid", Val::Expr("@char"))], "item_guid", guids(&removed_items)));
|
||||
}
|
||||
if ctx.probe.has("item_soulbound_trade_data") {
|
||||
st.delete(Delete::any_of("item_soulbound_trade_data", vec![], "itemGuid", guids(&removed_items)));
|
||||
}
|
||||
}
|
||||
for id in ®ift {
|
||||
st.delete(Delete::new("character_gifts", key_char(vec![("item_guid", Val::u(*ctx.items.get(id).expect("mapped")))])));
|
||||
}
|
||||
|
||||
let mut item_rows = Insert::new(
|
||||
"item_instance",
|
||||
&["guid", "itemEntry", "owner_guid", "creatorGuid", "giftCreatorGuid", "count", "duration", "charges", "flags", "enchantments", "randomPropertyId", "durability", "playedTime", "text"],
|
||||
);
|
||||
let mut inventory = Insert::new("character_inventory", &["guid", "bag", "slot", "item"]);
|
||||
let mut gifts = Insert::new("character_gifts", &["guid", "item_guid", "entry", "flags"]);
|
||||
for id in &added_items {
|
||||
let item = new_items[id];
|
||||
item_rows.row(vec![
|
||||
local(id)?,
|
||||
Val::u(content_id("item", &item.entry, ns, "item")?),
|
||||
Val::Expr("@char"),
|
||||
Val::u(0u8), // the crafter named a character of another realm
|
||||
Val::u(0u8),
|
||||
Val::u(item.count),
|
||||
Val::i(item.duration),
|
||||
Val::opt_text(charges_text(item).as_deref()),
|
||||
Val::u(item.flags),
|
||||
Val::text(enchantment_text(item)),
|
||||
Val::i(item.random_property_id),
|
||||
Val::u(item.durability),
|
||||
Val::u(item.played_time),
|
||||
Val::opt_text(item.text.as_deref()),
|
||||
])?;
|
||||
inventory.row(vec![Val::Expr("@char"), bag_of(item)?, Val::u(item.slot), local(id)?])?;
|
||||
if let Some(g) = &item.gift {
|
||||
gifts.row(vec![Val::Expr("@char"), local(id)?, Val::u(content_id("gift", &g.entry, ns, "item")?), Val::u(g.flags)])?;
|
||||
}
|
||||
}
|
||||
for id in &moved_inventory {
|
||||
let item = new_items[id];
|
||||
inventory.row(vec![Val::Expr("@char"), bag_of(item)?, Val::u(item.slot), local(id)?])?;
|
||||
}
|
||||
for id in ®ift {
|
||||
if let Some(g) = &new_items[id].gift {
|
||||
gifts.row(vec![Val::Expr("@char"), local(id)?, Val::u(content_id("gift", &g.entry, ns, "item")?), Val::u(g.flags)])?;
|
||||
}
|
||||
}
|
||||
st.insert(item_rows);
|
||||
st.insert(inventory);
|
||||
st.insert(gifts);
|
||||
|
||||
// ---- pets -------------------------------------------------------------------------------------------------------
|
||||
let cur_pets: HashMap<PortablePetId, &PortablePet> = current.pets.iter().map(|p| (p.id, p)).collect();
|
||||
let new_pets: HashMap<PortablePetId, &PortablePet> = merged.pets.iter().map(|p| (p.id, p)).collect();
|
||||
for id in cur_pets.keys() {
|
||||
if !ctx.pets.contains_key(id) {
|
||||
return Err(PortableError::Invalid(format!("internal: pet {id} of the realm has no local number")));
|
||||
}
|
||||
}
|
||||
let removed_pets: Vec<PortablePetId> = current.pets.iter().map(|p| p.id).filter(|id| !new_pets.contains_key(id)).collect();
|
||||
let added_pets: Vec<PortablePetId> = merged.pets.iter().map(|p| p.id).filter(|id| !cur_pets.contains_key(id)).collect();
|
||||
counts.pets_removed = removed_pets.len();
|
||||
counts.pets_added = added_pets.len();
|
||||
|
||||
if !removed_pets.is_empty() {
|
||||
let numbers: Vec<Val> = removed_pets.iter().map(|id| Val::u(*ctx.pets.get(id).expect("mapped"))).collect();
|
||||
st.delete(Delete::any_of("character_pet", vec![("owner", Val::Expr("@char"))], "id", numbers.clone()));
|
||||
st.delete(Delete::any_of("pet_spell", vec![], "guid", numbers.clone()));
|
||||
for table in ["pet_aura", "pet_spell_cooldown"] {
|
||||
if ctx.probe.has(table) {
|
||||
st.delete(Delete::any_of(table, vec![], "guid", numbers.clone()));
|
||||
}
|
||||
}
|
||||
if ctx.probe.has("character_pet_declinedname") {
|
||||
st.delete(Delete::any_of("character_pet_declinedname", vec![("owner", Val::Expr("@char"))], "id", numbers));
|
||||
}
|
||||
}
|
||||
let mut pet_spell_rows = Insert::new("pet_spell", &["guid", "spell", "active"]);
|
||||
let mut declined = Insert::new("character_pet_declinedname", &["id", "owner", "genitive", "dative", "accusative", "instrumental", "prepositional"]);
|
||||
for pet in &merged.pets {
|
||||
let Some(old) = cur_pets.get(&pet.id) else { continue };
|
||||
let number = *ctx.pets.get(&pet.id).expect("checked above");
|
||||
if **old == *pet {
|
||||
continue;
|
||||
}
|
||||
if old.entry != pet.entry || old.pet_type != pet.pet_type || old.created_by_spell != pet.created_by_spell {
|
||||
return Err(cannot("what a pet is (creature, kind or summoning spell)"));
|
||||
}
|
||||
counts.pets_changed += 1;
|
||||
let mut u = Update::new("character_pet", vec![("id", Val::u(number)), ("owner", Val::Expr("@char"))]);
|
||||
if old.model_id != pet.model_id {
|
||||
u.set("modelid", Val::u(pet.model_id));
|
||||
}
|
||||
if old.level != pet.level {
|
||||
u.set("level", Val::u(pet.level));
|
||||
}
|
||||
if old.exp != pet.exp {
|
||||
u.set("exp", Val::u(pet.exp));
|
||||
}
|
||||
if old.react_state != pet.react_state {
|
||||
u.set("Reactstate", Val::u(pet.react_state));
|
||||
}
|
||||
if old.name != pet.name {
|
||||
u.set("name", Val::text(pet.name.clone()));
|
||||
}
|
||||
if old.renamed != pet.renamed {
|
||||
u.set("renamed", Val::u(pet.renamed as u8));
|
||||
}
|
||||
if old.slot != pet.slot {
|
||||
u.set("slot", Val::u(pet.slot));
|
||||
}
|
||||
if old.health != pet.health {
|
||||
u.set("curhealth", Val::u(pet.health));
|
||||
}
|
||||
if old.mana != pet.mana {
|
||||
u.set("curmana", Val::u(pet.mana));
|
||||
}
|
||||
if old.happiness != pet.happiness {
|
||||
u.set("curhappiness", Val::u(pet.happiness));
|
||||
}
|
||||
if old.action_bar != pet.action_bar {
|
||||
u.set("abdata", if pet.action_bar.is_empty() { Val::Null } else { Val::text(pet.action_bar.clone()) });
|
||||
}
|
||||
st.update(u);
|
||||
let spells = |p: &PortablePet| -> BTreeMap<u32, u8> { p.spells.iter().map(|s| (s.spell, s.active)).collect() };
|
||||
let (del, ins) = diff(&spells(old), &spells(pet));
|
||||
counts.keyed_rows += del.len() + ins.len();
|
||||
st.delete(Delete::any_of("pet_spell", vec![("guid", Val::u(number))], "spell", del.iter().map(|s| Val::u(*s)).collect()));
|
||||
for (spell, active) in ins {
|
||||
pet_spell_rows.row(vec![Val::u(number), Val::u(spell), Val::u(active)])?;
|
||||
}
|
||||
if old.declined_names != pet.declined_names {
|
||||
counts.keyed_rows += 1;
|
||||
st.delete(Delete::new("character_pet_declinedname", vec![("id", Val::u(number)), ("owner", Val::Expr("@char"))]));
|
||||
if let Some(names) = &pet.declined_names {
|
||||
let mut r = vec![Val::u(number), Val::Expr("@char")];
|
||||
r.extend(names.iter().map(|n| Val::text(n.clone())));
|
||||
declined.row(r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pet_rows = Insert::new(
|
||||
"character_pet",
|
||||
&["id", "entry", "owner", "modelid", "CreatedBySpell", "PetType", "level", "exp", "Reactstate", "name", "renamed", "slot", "curhealth", "curmana", "curhappiness", "savetime", "abdata"],
|
||||
);
|
||||
for (i, id) in added_pets.iter().enumerate() {
|
||||
let pet = new_pets[id];
|
||||
let i = i as u32;
|
||||
pet_rows.row(vec![
|
||||
Val::PetRef(i),
|
||||
Val::u(content_id("pet", &pet.entry, ns, "creature")?),
|
||||
Val::Expr("@char"),
|
||||
Val::u(pet.model_id),
|
||||
Val::u(pet.created_by_spell),
|
||||
Val::u(pet.pet_type),
|
||||
Val::u(pet.level),
|
||||
Val::u(pet.exp),
|
||||
Val::u(pet.react_state),
|
||||
Val::text(pet.name.clone()),
|
||||
Val::u(pet.renamed as u8),
|
||||
Val::u(pet.slot),
|
||||
Val::u(pet.health),
|
||||
Val::u(pet.mana),
|
||||
Val::u(pet.happiness),
|
||||
Val::Expr("UNIX_TIMESTAMP()"),
|
||||
if pet.action_bar.is_empty() { Val::Null } else { Val::text(pet.action_bar.clone()) },
|
||||
])?;
|
||||
for s in &pet.spells {
|
||||
pet_spell_rows.row(vec![Val::PetRef(i), Val::u(s.spell), Val::u(s.active)])?;
|
||||
}
|
||||
if let Some(names) = &pet.declined_names {
|
||||
let mut r = vec![Val::PetRef(i), Val::Expr("@char")];
|
||||
r.extend(names.iter().map(|n| Val::text(n.clone())));
|
||||
declined.row(r)?;
|
||||
}
|
||||
}
|
||||
st.insert(pet_rows);
|
||||
st.insert(pet_spell_rows);
|
||||
st.insert(declined);
|
||||
|
||||
// ---- the script -------------------------------------------------------------------------------------------------
|
||||
let marker = super::plan::marker_data(ctx.nonce, ctx.revision);
|
||||
let mut s = String::new();
|
||||
let mut line = |text: String| {
|
||||
s.push_str(&text);
|
||||
s.push('\n');
|
||||
};
|
||||
line(format!("DO IF(GET_LOCK('{IMPORT_LOCK}', 10) = 1, 1, {FAIL});"));
|
||||
line("SET SESSION innodb_lock_wait_timeout = 10;".into());
|
||||
line("START TRANSACTION;".into());
|
||||
line(format!("DO IF((SELECT COUNT(*) FROM acore_characters.characters WHERE online <> 0) = 0, 1, {FAIL});"));
|
||||
let users = ctx.game_server_users.iter().map(|u| Val::text(u.clone()).sql()).collect::<Vec<_>>().join(", ");
|
||||
line(format!("DO IF((SELECT COUNT(*) FROM information_schema.processlist WHERE id <> CONNECTION_ID() AND user IN ({users})) = 0, 1, {FAIL});"));
|
||||
line(format!("SET @char := {};", ctx.local_guid));
|
||||
// the character is what was read: another writer between the read and this transaction fails the update instead of being overwritten
|
||||
line(format!(
|
||||
"DO IF((SELECT COUNT(*) FROM acore_characters.characters WHERE guid = @char AND deleteDate IS NULL AND level = {} AND xp = {} AND money = {} AND name = {}) = 1, 1, {FAIL});",
|
||||
p.level,
|
||||
p.xp,
|
||||
p.money,
|
||||
Val::text(ci.name.clone()).sql()
|
||||
));
|
||||
line("SET @items_before := (SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = @char);".into());
|
||||
line("SET @inventory_before := (SELECT COUNT(*) FROM acore_characters.character_inventory WHERE guid = @char);".into());
|
||||
line("SET @pets_before := (SELECT COUNT(*) FROM acore_characters.character_pet WHERE owner = @char);".into());
|
||||
// rows the realm itself would clean up on load (an inventory row without its item, an item in a bag that is not there) may
|
||||
// exist before the update; the update must neither create nor repair them, so the assertions compare against these
|
||||
line(format!("SET @orphans_before := {ORPHAN_ITEMS};"));
|
||||
line(format!("SET @bagless_before := {BAGLESS_ITEMS};"));
|
||||
line(format!("SET @item_base := {} + 1;", max_of(ctx.probe, ITEM_KEYED)));
|
||||
line(format!("SET @pet_base := {} + 1;", max_of(ctx.probe, PET_KEYED)));
|
||||
line(format!("DO IF(@item_base + {} < 4294967000 AND @pet_base + {} < 4294967000, 1, {FAIL});", added_items.len(), added_pets.len()));
|
||||
for g in &st.guards {
|
||||
line(g.clone());
|
||||
}
|
||||
for sql in st.deletes.iter().chain(&st.updates).chain(&st.inserts) {
|
||||
line(sql.clone());
|
||||
}
|
||||
|
||||
// the transaction checks its own result
|
||||
line(format!("DO IF((SELECT COUNT(*) FROM acore_characters.item_instance WHERE owner_guid = @char) = @items_before + {} - {}, 1, {FAIL});", added_items.len(), removed_items.len()));
|
||||
line(format!("DO IF((SELECT COUNT(*) FROM acore_characters.character_inventory WHERE guid = @char) = @inventory_before + {} - {}, 1, {FAIL});", added_items.len(), removed_items.len()));
|
||||
line(format!("DO IF((SELECT COUNT(*) FROM acore_characters.character_pet WHERE owner = @char) = @pets_before + {} - {}, 1, {FAIL});", added_pets.len(), removed_pets.len()));
|
||||
line(format!("DO IF({ORPHAN_ITEMS} = @orphans_before, 1, {FAIL});"));
|
||||
line(format!("DO IF({BAGLESS_ITEMS} = @bagless_before, 1, {FAIL});"));
|
||||
line(format!("DO IF((SELECT COUNT(*) FROM acore_characters.characters WHERE guid = @char) = 1, 1, {FAIL});"));
|
||||
|
||||
// the markers: replaced, not added
|
||||
let markers = [Val::text(IMPORT_MARKER_SOURCE), Val::text(ALLOC_MARKER_SOURCE)];
|
||||
line(format!("DELETE FROM acore_characters.`character_settings` WHERE `guid` = @char AND `source` IN ({}, {});", markers[0].sql(), markers[1].sql()));
|
||||
let mut m = Insert::new("character_settings", &["guid", "source", "data"]);
|
||||
m.row(vec![Val::Expr("@char"), markers[0].clone(), Val::text(marker.clone())])?;
|
||||
m.row(vec![Val::Expr("@char"), markers[1].clone(), Val::Expr("CONCAT(@item_base, ' ', @pet_base, ' ')")])?;
|
||||
line(m.sql().expect("two rows"));
|
||||
|
||||
line("SELECT '#R:update', @char, @item_base, @pet_base;".into());
|
||||
line("COMMIT;".into());
|
||||
line("SELECT '#R:committed';".into());
|
||||
line(format!("DO RELEASE_LOCK('{IMPORT_LOCK}');"));
|
||||
|
||||
Ok(UpdateScript { script: s, added_items, added_pets, removed_items, removed_pets, counts, marker_data: marker })
|
||||
}
|
||||
|
||||
/// What the realm answered after a successful update: (local guid, item base, pet base, committed).
|
||||
pub fn parse_update_report(output: &str) -> Result<((u32, u32, u32), bool)> {
|
||||
let mut found = None;
|
||||
let mut committed = false;
|
||||
for line in output.lines().filter(|l| !l.is_empty()) {
|
||||
let cells: Vec<&str> = line.split('\t').collect();
|
||||
match cells[0] {
|
||||
"#R:update" if cells.len() == 4 => {
|
||||
let num = |i: usize| cells[i].parse::<u32>().map_err(|_| PortableError::CorruptSnapshot(format!("update report: {:?} is not a number", cells[i])));
|
||||
found = Some((num(1)?, num(2)?, num(3)?));
|
||||
}
|
||||
"#R:committed" => committed = true,
|
||||
other => return Err(PortableError::CorruptSnapshot(format!("unexpected line in the update report: {other:?}"))),
|
||||
}
|
||||
}
|
||||
Ok((found.ok_or_else(|| PortableError::CorruptSnapshot("the update report has no allocation".into()))?, committed))
|
||||
}
|
||||
|
||||
/// Items the update will give the realm that its `item_template` must know, and pet creatures: for the preflight.
|
||||
pub fn new_content(merged: &PortableCharacter, added_items: &[PortableItemId], added_pets: &[PortablePetId]) -> (BTreeSet<u32>, BTreeSet<u32>) {
|
||||
let items: BTreeSet<u32> = merged
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| added_items.contains(&i.id))
|
||||
.flat_map(|i| std::iter::once(i.entry.id()).chain(i.gift.as_ref().map(|g| g.entry.id())))
|
||||
.filter_map(|e| u32::try_from(e).ok())
|
||||
.collect();
|
||||
let pets: BTreeSet<u32> = merged.pets.iter().filter(|p| added_pets.contains(&p.id)).filter_map(|p| u32::try_from(p.entry.id()).ok()).collect();
|
||||
(items, pets)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Offline tests of the in-place update script: what it writes, what it must never write, and that hostile text stays inert.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::super::fixtures::{geared_level_eighty, id7};
|
||||
use super::super::ids::{ContentId, PortableItemId, PortablePetId};
|
||||
use super::super::model::*;
|
||||
use super::script::SchemaProbe;
|
||||
use super::update::*;
|
||||
|
||||
fn probe() -> SchemaProbe {
|
||||
let tables = ["reserved_name", "item_refund_instance", "item_soulbound_trade_data", "pet_aura", "pet_spell_cooldown", "character_pet_declinedname", "character_gifts"];
|
||||
SchemaProbe { tables: tables.iter().map(|t| t.to_string()).collect(), character_columns: Default::default() }
|
||||
}
|
||||
|
||||
fn realm_ids(current: &PortableCharacter) -> (HashMap<PortableItemId, u32>, HashMap<PortablePetId, u32>) {
|
||||
(current.items.iter().enumerate().map(|(i, it)| (it.id, 5000 + i as u32)).collect(), current.pets.iter().enumerate().map(|(i, p)| (p.id, 70 + i as u32)).collect())
|
||||
}
|
||||
|
||||
fn script_of(current: &PortableCharacter, merged: &PortableCharacter) -> UpdateScript {
|
||||
let (items, pets) = realm_ids(current);
|
||||
let users = ["acore".to_string()];
|
||||
let probe = probe();
|
||||
build_update(current, merged, &UpdateContext { ruleset: Ruleset::Coa, local_guid: 4242, revision: 9, nonce: [1, 2, 3, 4], game_server_users: &users, probe: &probe, items: &items, pets: &pets }).unwrap()
|
||||
}
|
||||
|
||||
fn statements(script: &UpdateScript) -> Vec<&str> {
|
||||
script.script.lines().collect()
|
||||
}
|
||||
|
||||
fn writes(script: &UpdateScript) -> Vec<&str> {
|
||||
statements(script).into_iter().filter(|l| l.starts_with("UPDATE ") || l.starts_with("INSERT ") || l.starts_with("DELETE ")).collect()
|
||||
}
|
||||
|
||||
fn new_item(n: u64, slot: u8, container: Option<PortableItemId>) -> PortableItem {
|
||||
PortableItem {
|
||||
id: PortableItemId::from_uuid(id7(800_000 + n)).unwrap(),
|
||||
container,
|
||||
slot,
|
||||
entry: ContentId::new("coa", "item", 88_000 + n).unwrap(),
|
||||
count: 1,
|
||||
duration: 0,
|
||||
charges: vec![],
|
||||
flags: 0,
|
||||
enchantments: vec![],
|
||||
random_property_id: 0,
|
||||
durability: 40,
|
||||
played_time: 0,
|
||||
text: None,
|
||||
creator_name: None,
|
||||
gift: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unchanged_character_is_updated_by_nothing_but_the_marker() {
|
||||
let current = geared_level_eighty();
|
||||
let script = script_of(¤t, ¤t.clone());
|
||||
assert!(script.counts.is_empty(), "{:?}", script.counts);
|
||||
let w = writes(&script);
|
||||
assert_eq!(w.len(), 2, "only the marker rows are written: {w:?}");
|
||||
assert!(w[0].starts_with("DELETE FROM acore_characters.`character_settings`"));
|
||||
assert!(w[1].starts_with("INSERT INTO acore_characters.`character_settings`"));
|
||||
assert!(script.script.contains("coa.portable.alloc") || script.script.contains(&hex::encode("coa.portable.alloc")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_columns_that_changed_are_written_and_never_the_position() {
|
||||
let current = geared_level_eighty();
|
||||
let mut merged = current.clone();
|
||||
merged.progression.money += 12_345;
|
||||
merged.progression.level = 81;
|
||||
merged.progression.xp = 7;
|
||||
merged.progression.honor.total_kills += 3;
|
||||
let script = script_of(¤t, &merged);
|
||||
let w = writes(&script);
|
||||
let updates: Vec<&&str> = w.iter().filter(|l| l.starts_with("UPDATE")).collect();
|
||||
assert_eq!(updates.len(), 1, "{w:?}");
|
||||
let u = updates[0];
|
||||
for column in ["`level`", "`xp`", "`money`", "`totalKills`"] {
|
||||
assert!(u.contains(column), "{u}");
|
||||
}
|
||||
assert!(!u.contains("`name`") && !u.contains("`totalHonorPoints`") && !u.contains("`skin`"), "unchanged columns are not written: {u}");
|
||||
assert!(u.ends_with("WHERE `guid` = @char;"));
|
||||
assert_eq!(script.counts.character_columns, 4);
|
||||
// nothing of the realm's own placement is ever mentioned
|
||||
for forbidden in ["character_homebind", "position_x", "position_y", "position_z", "`map`", "`zone`", "orientation", "logout_time", "`online`", "`account`", "`guid` = 4242"] {
|
||||
assert!(!script.script.contains(forbidden), "the update must not touch {forbidden}");
|
||||
}
|
||||
assert!(!script.script.contains("INSERT INTO acore_characters.`characters`") && !script.script.contains("DELETE FROM acore_characters.`characters`"), "the character row is never created or deleted");
|
||||
assert!(script.script.contains("SET @char := 4242;"), "the character is addressed by its existing local guid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removed_items_are_deleted_by_their_mapped_guid_and_new_ones_are_allocated_in_the_transaction() {
|
||||
let current = geared_level_eighty();
|
||||
let (ids, _) = realm_ids(¤t);
|
||||
let mut merged = current.clone();
|
||||
let gone = merged.items.iter().position(|i| i.container.is_none() && i.slot >= 23 && !current.items.iter().any(|o| o.container == Some(i.id))).unwrap();
|
||||
let gone_id = merged.items.remove(gone).id;
|
||||
let fresh = new_item(1, 66, None);
|
||||
merged.items.push(fresh.clone());
|
||||
let merged = merged.normalized();
|
||||
let script = script_of(¤t, &merged);
|
||||
assert_eq!(script.removed_items, vec![gone_id]);
|
||||
assert_eq!(script.added_items, vec![fresh.id]);
|
||||
let guid = ids[&gone_id];
|
||||
let all = script.script.clone();
|
||||
assert!(all.contains(&format!("DELETE FROM acore_characters.`item_instance` WHERE `owner_guid` = @char AND `guid` IN ({guid});")), "{all}");
|
||||
assert!(all.contains(&format!("DELETE FROM acore_characters.`character_inventory` WHERE `guid` = @char AND `item` IN ({guid});")));
|
||||
assert!(all.contains("(@item_base + 0)"), "the new item gets a guid allocated inside the transaction");
|
||||
assert_eq!((script.counts.items_added, script.counts.items_removed), (1, 1));
|
||||
// deletes run before updates before inserts
|
||||
let w = writes(&script);
|
||||
let first_insert = w.iter().position(|l| l.starts_with("INSERT")).unwrap();
|
||||
let last_delete = w.iter().rposition(|l| l.starts_with("DELETE")).unwrap();
|
||||
assert!(last_delete < first_insert || w[last_delete].contains("character_settings"), "{w:?}");
|
||||
// every assertion comes before the commit, and the commit is the only one
|
||||
let s = statements(&script);
|
||||
let commit = s.iter().position(|l| *l == "COMMIT;").unwrap();
|
||||
assert_eq!(s.iter().filter(|l| **l == "COMMIT;").count(), 1);
|
||||
assert!(s.iter().enumerate().filter(|(_, l)| l.starts_with("DO IF(") && l.contains("@items_before")).all(|(i, _)| i < commit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_items_free_their_places_before_anything_takes_them() {
|
||||
let current = geared_level_eighty();
|
||||
let mut merged = current.clone();
|
||||
// two backpack items swap places: a swap collides on the unique place key unless both rows leave first
|
||||
let free: Vec<usize> = merged.items.iter().enumerate().filter(|(_, i)| i.container.is_none() && (23..39).contains(&i.slot)).map(|(n, _)| n).take(2).collect();
|
||||
assert_eq!(free.len(), 2, "the fixture has two backpack items");
|
||||
let (a, b) = (merged.items[free[0]].slot, merged.items[free[1]].slot);
|
||||
merged.items[free[0]].slot = b;
|
||||
merged.items[free[1]].slot = a;
|
||||
let script = script_of(¤t, &merged);
|
||||
assert_eq!(script.counts.items_moved, 2);
|
||||
let w = writes(&script);
|
||||
let delete = w.iter().position(|l| l.starts_with("DELETE FROM acore_characters.`character_inventory`")).unwrap();
|
||||
let insert = w.iter().position(|l| l.starts_with("INSERT INTO acore_characters.`character_inventory`")).unwrap();
|
||||
assert!(delete < insert, "{w:?}");
|
||||
assert!(w[delete].matches(',').count() >= 1, "both rows leave in one statement: {}", w[delete]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_text_reaches_the_script_only_as_hex() {
|
||||
let current = geared_level_eighty();
|
||||
let mut merged = current.clone();
|
||||
let evil = "x'); DROP TABLE characters; -- \\ \" \n \0 /* */";
|
||||
merged.identity.name = "Renamed".into();
|
||||
merged.items.push({
|
||||
let mut i = new_item(2, 66, None);
|
||||
i.text = Some(evil.into());
|
||||
i
|
||||
});
|
||||
merged.settings.insert("core.ascension_build.77".into(), vec![1, 2, 3]);
|
||||
let script = script_of(¤t, &merged.normalized());
|
||||
assert!(!script.script.contains("DROP TABLE"), "{}", script.script);
|
||||
assert!(!script.script.contains('\0'));
|
||||
assert!(script.script.contains(&hex::encode(evil)), "the text is there, as hex");
|
||||
assert!(script.script.contains(&hex::encode("Renamed")));
|
||||
// a rename is guarded against a taken or reserved name
|
||||
assert!(script.script.contains("reserved_name"));
|
||||
assert!(script.script.contains("guid <> @char"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarantined_settings_are_never_written_even_if_a_model_carries_them() {
|
||||
let current = geared_level_eighty();
|
||||
let mut merged = current.clone();
|
||||
merged.settings.insert("core.spell_charge.804197".into(), vec![9]);
|
||||
merged.settings.insert("core.wildcard.cards".into(), vec![9]);
|
||||
merged.settings.insert("coa.portable.import".into(), vec![9]);
|
||||
let script = script_of(¤t, &merged);
|
||||
for source in ["core.spell_charge.804197", "core.wildcard.cards", "coa.portable.import"] {
|
||||
assert!(!script.script.contains(&hex::encode(source)) || source == "coa.portable.import", "{source} must not be written");
|
||||
}
|
||||
// the marker the script writes is its own, not one smuggled in through the model
|
||||
assert_eq!(script.script.matches(&hex::encode("coa.portable.import")).count(), 2, "delete + insert of the importer's own marker only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_character_is_cannot_be_changed_in_place() {
|
||||
let current = geared_level_eighty();
|
||||
let (items, pets) = realm_ids(¤t);
|
||||
let users = ["acore".to_string()];
|
||||
let probe = probe();
|
||||
let ctx = UpdateContext { ruleset: Ruleset::Coa, local_guid: 1, revision: 2, nonce: [0; 4], game_server_users: &users, probe: &probe, items: &items, pets: &pets };
|
||||
let mut merged = current.clone();
|
||||
merged.identity.gender ^= 1;
|
||||
assert!(build_update(¤t, &merged, &ctx).is_err(), "gender");
|
||||
let mut merged = current.clone();
|
||||
merged.identity.class = ContentId::new("coa", "class", 99).unwrap();
|
||||
assert!(build_update(¤t, &merged, &ctx).is_err(), "class");
|
||||
let mut merged = current.clone();
|
||||
merged.ruleset = Ruleset::Wildcard;
|
||||
assert!(build_update(¤t, &merged, &ctx).is_err(), "ruleset");
|
||||
let mut merged = current.clone();
|
||||
if let Some(p) = merged.pets.first_mut() {
|
||||
p.pet_type ^= 1;
|
||||
assert!(build_update(¤t, &merged, &ctx).is_err(), "pet kind");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_pet_is_allocated_in_the_transaction_and_a_removed_pet_leaves_every_table() {
|
||||
let current = geared_level_eighty();
|
||||
assert!(!current.pets.is_empty(), "the fixture has a pet");
|
||||
let removed = current.pets[0].id;
|
||||
let mut merged = current.clone();
|
||||
let mut pet = merged.pets.remove(0);
|
||||
pet.id = PortablePetId::from_uuid(id7(777_000)).unwrap();
|
||||
merged.pets.push(pet);
|
||||
let script = script_of(¤t, &merged);
|
||||
assert_eq!((script.removed_pets.clone(), script.added_pets.len()), (vec![removed], 1));
|
||||
for table in ["character_pet", "pet_spell", "pet_aura", "pet_spell_cooldown", "character_pet_declinedname"] {
|
||||
assert!(script.script.contains(&format!("DELETE FROM acore_characters.`{table}` WHERE")), "{table}");
|
||||
}
|
||||
assert!(script.script.contains("(@pet_base + 0)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_report_parses_and_a_lost_commit_is_visible() {
|
||||
assert_eq!(parse_update_report("#R:update\t4242\t900\t12\n#R:committed\n").unwrap(), ((4242, 900, 12), true));
|
||||
assert_eq!(parse_update_report("#R:update\t4242\t900\t12\n").unwrap(), ((4242, 900, 12), false));
|
||||
assert!(parse_update_report("").is_err());
|
||||
assert!(parse_update_report("#R:alloc\t1\t2\t3\t4\t5\n").is_err());
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use super::versions::{PORTABLE_COLLECTION_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSIO
|
||||
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"), include_str!("migrations/002_item_mapping_lifecycle.sql"), include_str!("migrations/003_import_journal.sql")];
|
||||
const MIGRATIONS: &[&str] = &[include_str!("migrations/001_init.sql"), include_str!("migrations/002_item_mapping_lifecycle.sql"), include_str!("migrations/003_import_journal.sql"), include_str!("migrations/004_reconciliation.sql")];
|
||||
|
||||
pub const DEFAULT_HISTORY_KEEP: u32 = 20;
|
||||
pub const MAX_HISTORY_KEEP: u32 = 1_000;
|
||||
@@ -473,6 +473,8 @@ impl Store {
|
||||
create_in_tx(&tx, r.profile, &r.model, &encoded, r.source_server_id)?;
|
||||
bind_in_tx(&tx, id, r.server_id, r.local_guid, 1, MappingState::Synced)?;
|
||||
reconcile_in_tx(&tx, id, r.server_id, 1, r.observations)?;
|
||||
sync_pets_in_tx(&tx, id, r.server_id, 1, r.pets, &PetProtection::default())?;
|
||||
set_synced_in_tx(&tx, id, r.server_id, &encoded)?;
|
||||
tx.commit()?;
|
||||
Ok(id)
|
||||
}
|
||||
@@ -666,6 +668,7 @@ pub struct RealmRegistration<'a> {
|
||||
pub server_id: &'a str,
|
||||
pub local_guid: u32,
|
||||
pub observations: &'a [ItemObservation],
|
||||
pub pets: &'a [PetObservation],
|
||||
}
|
||||
|
||||
/// What a realm currently holds for one item of a character.
|
||||
@@ -713,14 +716,58 @@ pub struct ItemMapping {
|
||||
pub entry: String,
|
||||
pub identity: String,
|
||||
pub active: bool,
|
||||
/// Whether the realm currently holds the item (see [`Presence`]).
|
||||
pub presence: Presence,
|
||||
pub created_revision: u64,
|
||||
pub confirmed_revision: u64,
|
||||
pub retired_revision: Option<u64>,
|
||||
pub retired_reason: Option<RetireReason>,
|
||||
}
|
||||
|
||||
/// Whether the realm holds an item (or pet) that the canonical character owns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Presence {
|
||||
/// The realm has it.
|
||||
Present,
|
||||
/// The realm filtered it away before the session baseline (mailed, unknown entry, cannot equip, ...). The canonical
|
||||
/// character still owns it; this is **not** a deletion by the player, so the mapping stays active.
|
||||
Filtered,
|
||||
/// The realm's own addition: never merged into the canonical character.
|
||||
RealmLocal,
|
||||
}
|
||||
|
||||
impl Presence {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Presence::Present => "present",
|
||||
Presence::Filtered => "filtered",
|
||||
Presence::RealmLocal => "realm_local",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Result<Self> {
|
||||
Ok(match s {
|
||||
"present" => Presence::Present,
|
||||
"filtered" => Presence::Filtered,
|
||||
"realm_local" => Presence::RealmLocal,
|
||||
other => return Err(PortableError::Invalid(format!("unknown presence {other:?}"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What an observation must not retire / how its items are classified.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Protection {
|
||||
/// Ids the canonical character owns. An active mapping of such an item that the realm no longer shows becomes
|
||||
/// `filtered` instead of being retired.
|
||||
pub canonical_items: std::collections::HashSet<PortableItemId>,
|
||||
/// Observed ids that are the realm's own additions.
|
||||
pub realm_local_items: std::collections::HashSet<PortableItemId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ReconcileReport {
|
||||
/// Active mappings of canonical items that the realm does not show: kept as `filtered`.
|
||||
pub filtered: usize,
|
||||
pub confirmed: usize,
|
||||
pub added: usize,
|
||||
pub guid_reused: usize,
|
||||
@@ -784,7 +831,7 @@ fn retire_all(tx: &Transaction<'_>, id: CharacterId, server_id: &str, reason: Re
|
||||
|
||||
fn read_item_mappings(conn: &Connection, id: CharacterId, server_id: &str, include_retired: bool) -> Result<Vec<ItemMapping>> {
|
||||
let sql = format!(
|
||||
"SELECT portable_item_id, local_item_guid, entry, identity, state, created_revision, confirmed_revision, retired_revision, retired_reason
|
||||
"SELECT portable_item_id, local_item_guid, entry, identity, state, created_revision, confirmed_revision, retired_revision, retired_reason, presence
|
||||
FROM item_mapping WHERE character_id = ?1 AND server_id = ?2 {} ORDER BY mapping_id",
|
||||
if include_retired { "" } else { "AND state = 'active'" }
|
||||
);
|
||||
@@ -800,17 +847,19 @@ fn read_item_mappings(conn: &Connection, id: CharacterId, server_id: &str, inclu
|
||||
r.get::<_, i64>(6)?,
|
||||
r.get::<_, Option<i64>>(7)?,
|
||||
r.get::<_, Option<String>>(8)?,
|
||||
r.get::<_, String>(9)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (item, guid, entry, identity, state, created, confirmed, retired, reason) = row?;
|
||||
let (item, guid, entry, identity, state, created, confirmed, retired, reason, presence) = row?;
|
||||
out.push(ItemMapping {
|
||||
portable_item_id: item.parse()?,
|
||||
local_item_guid: guid as u32,
|
||||
entry,
|
||||
identity,
|
||||
active: state == "active",
|
||||
presence: Presence::parse(&presence)?,
|
||||
created_revision: created as u64,
|
||||
confirmed_revision: confirmed as u64,
|
||||
retired_revision: retired.map(|r| r as u64),
|
||||
@@ -866,6 +915,7 @@ fn bind_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, local_guid
|
||||
.optional()?;
|
||||
if previous.is_some_and(|p| p != local_guid as i64) {
|
||||
retire_all(tx, id, server_id, RetireReason::CharacterRebound, last_revision)?;
|
||||
retire_all_pets(tx, id, server_id, RetireReason::CharacterRebound, last_revision)?;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at)
|
||||
@@ -878,6 +928,10 @@ fn bind_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, local_guid
|
||||
}
|
||||
|
||||
fn reconcile_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revision: u64, observations: &[ItemObservation]) -> Result<ReconcileReport> {
|
||||
reconcile_with(tx, id, server_id, revision, observations, &Protection::default())
|
||||
}
|
||||
|
||||
fn reconcile_with(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revision: u64, observations: &[ItemObservation], protection: &Protection) -> Result<ReconcileReport> {
|
||||
validate_observations(server_id, observations)?;
|
||||
let record = read_character(tx, id)?;
|
||||
if revision > record.revision {
|
||||
@@ -898,7 +952,7 @@ fn reconcile_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revis
|
||||
let by_guid = active_by_guid(tx, id, server_id, o.local_item_guid)?;
|
||||
if let Some(existing) = &by_guid {
|
||||
if existing.portable_item_id == o.portable_item_id && existing.identity == o.identity {
|
||||
tx.execute("UPDATE item_mapping SET confirmed_revision = ?1, updated_at = ?2 WHERE mapping_id = ?3", params![revision as i64, at, existing.mapping_id])?;
|
||||
tx.execute("UPDATE item_mapping SET confirmed_revision = ?1, updated_at = ?2, presence = ?4 WHERE mapping_id = ?3", params![revision as i64, at, existing.mapping_id, presence_of(protection, o.portable_item_id).as_str()])?;
|
||||
report.confirmed += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -912,10 +966,10 @@ fn reconcile_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revis
|
||||
report.moved += 1;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid, entry, identity, state,
|
||||
"INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid, entry, identity, state, presence,
|
||||
created_revision, confirmed_revision, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?7, ?8, ?8)",
|
||||
params![id.to_string(), server_id, o.portable_item_id.to_string(), o.local_item_guid, o.entry.to_string(), o.identity, revision as i64, at],
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?9, ?7, ?7, ?8, ?8)",
|
||||
params![id.to_string(), server_id, o.portable_item_id.to_string(), o.local_item_guid, o.entry.to_string(), o.identity, revision as i64, at, presence_of(protection, o.portable_item_id).as_str()],
|
||||
)?;
|
||||
report.added += 1;
|
||||
}
|
||||
@@ -925,13 +979,28 @@ fn reconcile_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revis
|
||||
drop(stmt);
|
||||
for (mapping_id, item) in active {
|
||||
if !observations.iter().any(|o| o.portable_item_id.to_string() == item) {
|
||||
retire_one(tx, mapping_id, RetireReason::Absent, revision, &at)?;
|
||||
report.absent += 1;
|
||||
let owned = item.parse::<PortableItemId>().map(|i| protection.canonical_items.contains(&i)).unwrap_or(false);
|
||||
if owned {
|
||||
// filtered away by the realm, still the character's: keep the mapping
|
||||
tx.execute("UPDATE item_mapping SET presence = 'filtered', updated_at = ?2 WHERE mapping_id = ?1", params![mapping_id, at])?;
|
||||
report.filtered += 1;
|
||||
} else {
|
||||
retire_one(tx, mapping_id, RetireReason::Absent, revision, &at)?;
|
||||
report.absent += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn presence_of(protection: &Protection, id: PortableItemId) -> Presence {
|
||||
if protection.realm_local_items.contains(&id) {
|
||||
Presence::RealmLocal
|
||||
} else {
|
||||
Presence::Present
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_observations(server_id: &str, observations: &[ItemObservation]) -> Result<()> {
|
||||
let mut portable = std::collections::HashSet::new();
|
||||
let mut local = std::collections::HashSet::new();
|
||||
@@ -950,7 +1019,9 @@ fn validate_observations(server_id: &str, observations: &[ItemObservation]) -> R
|
||||
}
|
||||
|
||||
mod journal;
|
||||
mod sync;
|
||||
pub use journal::*;
|
||||
pub use sync::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,15 +1,19 @@
|
||||
//! The import journal: how a realm commit and a local commit stay consistent although they cannot be one transaction.
|
||||
//!
|
||||
//! ```text
|
||||
//! begin_import local commit 1 journal row "prepared" (nonce, the plan: portable ids, entries, identities)
|
||||
//! realm transaction inserts the character AND the marker `coa.portable.import = <nonce> <revision>`
|
||||
//! finish_import local commit 2 mappings + binding + journal "committed", in ONE local transaction
|
||||
//! begin_import / begin_update local commit 1 journal row "prepared" (nonce, the plan, the canonical snapshot synced)
|
||||
//! realm transaction writes the character (or its update) AND the marker
|
||||
//! `coa.portable.import = <nonce> <revision>`
|
||||
//! finish_import local commit 2 mappings + binding + synced snapshot + journal "committed", in ONE tx
|
||||
//! ```
|
||||
//!
|
||||
//! A crash can fall in two places. Before the realm commit: no marker exists, nothing happened in the realm; the entry is
|
||||
//! aborted. After the realm commit but before `finish_import`: the marker exists in the realm, so the import *did*
|
||||
//! happen and `finish_import` is simply run again from what the marker and the realm's rows say. The marker is part of
|
||||
//! the realm transaction, so "marker present" and "import committed" are the same fact.
|
||||
//!
|
||||
//! An **update** (an existing realm character brought to a newer canonical revision, in place) is journaled the same way;
|
||||
//! it additionally records which items and pets it retires from the realm's mappings.
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -17,7 +21,7 @@ use serde::{Deserialize, Serialize};
|
||||
use super::*;
|
||||
use crate::portable::ids::{ImportId, PortablePetId};
|
||||
|
||||
/// One item of the import plan, as far as the mapping needs to know it.
|
||||
/// One item of the plan, as far as the mapping needs to know it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlannedItem {
|
||||
pub id: PortableItemId,
|
||||
@@ -25,6 +29,38 @@ pub struct PlannedItem {
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// One pet of the plan.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PlannedPet {
|
||||
pub id: PortablePetId,
|
||||
pub entry: ContentId,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum JournalKind {
|
||||
/// A new character is created in the realm.
|
||||
Import,
|
||||
/// An existing realm character is updated in place.
|
||||
Update,
|
||||
}
|
||||
|
||||
impl JournalKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
JournalKind::Import => "import",
|
||||
JournalKind::Update => "update",
|
||||
}
|
||||
}
|
||||
fn parse(s: &str) -> Result<Self> {
|
||||
match s {
|
||||
"import" => Ok(JournalKind::Import),
|
||||
"update" => Ok(JournalKind::Update),
|
||||
other => Err(PortableError::Invalid(format!("unknown journal kind {other:?}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImportState {
|
||||
/// Written before the realm transaction; the realm may or may not have committed.
|
||||
@@ -76,13 +112,19 @@ pub struct ImportAllocation {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JournalEntry {
|
||||
pub import_id: ImportId,
|
||||
pub kind: JournalKind,
|
||||
pub character_id: CharacterId,
|
||||
pub server_id: String,
|
||||
pub revision: u64,
|
||||
pub marker: String,
|
||||
pub state: ImportState,
|
||||
/// Items the realm gets: item `i` is realm guid `item_base + i`.
|
||||
pub items: Vec<PlannedItem>,
|
||||
pub pet_ids: Vec<PortablePetId>,
|
||||
/// Pets the realm gets: pet `i` is `pet_base + i`.
|
||||
pub pets: Vec<PlannedPet>,
|
||||
/// An update only: items / pets removed from the realm.
|
||||
pub retired_items: Vec<PortableItemId>,
|
||||
pub retired_pets: Vec<PortablePetId>,
|
||||
pub allocation: Option<ImportAllocation>,
|
||||
pub detail: Option<String>,
|
||||
pub created_at: String,
|
||||
@@ -100,11 +142,29 @@ pub fn marker_text(nonce: [u32; 4], revision: u64) -> String {
|
||||
format!("{} {} {} {} {} ", nonce[0], nonce[1], nonce[2], nonce[3], revision)
|
||||
}
|
||||
|
||||
/// What an update changes in the realm's mappings.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct UpdatePlan {
|
||||
pub added_items: Vec<PlannedItem>,
|
||||
pub added_pets: Vec<PlannedPet>,
|
||||
pub retired_items: Vec<PortableItemId>,
|
||||
pub retired_pets: Vec<PortablePetId>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Record the intention to import `character` at its current canonical `revision` into `server_id`.
|
||||
/// Refused when the revision is stale, when the character is already on that server, or when another import of the
|
||||
/// same pair is unfinished.
|
||||
pub fn begin_import(&mut self, character: CharacterId, server_id: &str, revision: u64, items: &[PlannedItem], pet_ids: &[PortablePetId]) -> Result<ImportTicket> {
|
||||
pub fn begin_import(&mut self, character: CharacterId, server_id: &str, revision: u64, items: &[PlannedItem], pets: &[PlannedPet]) -> Result<ImportTicket> {
|
||||
self.begin(JournalKind::Import, character, server_id, revision, UpdatePlan { added_items: items.to_vec(), added_pets: pets.to_vec(), ..UpdatePlan::default() })
|
||||
}
|
||||
|
||||
/// Record the intention to bring the existing realm character to canonical `revision`, in place.
|
||||
pub fn begin_update(&mut self, character: CharacterId, server_id: &str, revision: u64, plan: UpdatePlan) -> Result<ImportTicket> {
|
||||
self.begin(JournalKind::Update, character, server_id, revision, plan)
|
||||
}
|
||||
|
||||
fn begin(&mut self, kind: JournalKind, character: CharacterId, server_id: &str, revision: u64, plan: UpdatePlan) -> Result<ImportTicket> {
|
||||
check_server_id(server_id)?;
|
||||
let tx = self.write_tx()?;
|
||||
let record = read_character(&tx, character)?;
|
||||
@@ -122,24 +182,42 @@ impl Store {
|
||||
params![character.to_string(), server_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if bound {
|
||||
return Err(PortableError::AlreadyOnRealm { character, server_id: server_id.to_string() });
|
||||
match (kind, bound) {
|
||||
(JournalKind::Import, true) => return Err(PortableError::AlreadyOnRealm { character, server_id: server_id.to_string() }),
|
||||
(JournalKind::Update, false) => return Err(PortableError::Invalid(format!("character {character} is not on server {server_id}: there is nothing to update"))),
|
||||
_ => {}
|
||||
}
|
||||
let (target, _) = read_snapshot_row(&tx, character, revision)?;
|
||||
let encoded = snapshot::encode(&target)?;
|
||||
let import_id = ImportId::new();
|
||||
let nonce = nonce();
|
||||
let marker = marker_text(nonce, revision);
|
||||
let at = now();
|
||||
tx.execute(
|
||||
"INSERT INTO import_journal(import_id, character_id, server_id, revision, marker, state, items, pet_ids, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'prepared', ?6, ?7, ?8, ?8)",
|
||||
params![import_id.to_string(), character.to_string(), server_id, revision as i64, marker, serde_json::to_string(items)?, serde_json::to_string(pet_ids)?, at],
|
||||
"INSERT INTO import_journal(import_id, character_id, server_id, revision, marker, state, items, pet_ids, kind, retired_items, retired_pets, target_hash, target_payload, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'prepared', ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?13)",
|
||||
params![
|
||||
import_id.to_string(),
|
||||
character.to_string(),
|
||||
server_id,
|
||||
revision as i64,
|
||||
marker,
|
||||
serde_json::to_string(&plan.added_items)?,
|
||||
serde_json::to_string(&plan.added_pets)?,
|
||||
kind.as_str(),
|
||||
serde_json::to_string(&plan.retired_items)?,
|
||||
serde_json::to_string(&plan.retired_pets)?,
|
||||
encoded.content_hash.as_slice(),
|
||||
encoded.payload,
|
||||
at
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(ImportTicket { import_id, nonce, revision, marker })
|
||||
}
|
||||
|
||||
/// The realm committed: record the binding and the item mappings and close the journal entry, in **one** local
|
||||
/// transaction. Safe to call again for an entry that is already committed (it does nothing).
|
||||
/// The realm committed: record the binding (or the update), the item and pet mappings, the synced snapshot and close the
|
||||
/// journal entry, in **one** local transaction. Safe to call again for an entry that is already committed.
|
||||
pub fn finish_import(&mut self, import_id: ImportId, allocation: ImportAllocation) -> Result<()> {
|
||||
let tx = self.write_tx()?;
|
||||
let entry = read_journal(&tx, import_id)?;
|
||||
@@ -148,13 +226,37 @@ impl Store {
|
||||
ImportState::Prepared => {}
|
||||
other => return Err(PortableError::ImportState { import_id, state: other.as_str().into() }),
|
||||
}
|
||||
let mut observations = Vec::with_capacity(entry.items.len());
|
||||
for (i, planned) in entry.items.iter().enumerate() {
|
||||
let guid = u32::try_from(allocation.item_base as u64 + i as u64).map_err(|_| PortableError::Invalid("item guid out of range".into()))?;
|
||||
observations.push(ItemObservation { portable_item_id: planned.id, local_item_guid: guid, entry: planned.entry.clone(), identity: planned.identity.clone() });
|
||||
let guid_of = |base: u32, i: usize| u32::try_from(base as u64 + i as u64).map_err(|_| PortableError::Invalid("a realm id is out of range".into()));
|
||||
let item_rows: Vec<(PortableItemId, u32, ContentId, String)> =
|
||||
entry.items.iter().enumerate().map(|(i, p)| Ok((p.id, guid_of(allocation.item_base, i)?, p.entry.clone(), p.identity.clone()))).collect::<Result<_>>()?;
|
||||
let pet_rows: Vec<(PortablePetId, u32, String)> = entry.pets.iter().enumerate().map(|(i, p)| Ok((p.id, guid_of(allocation.pet_base, i)?, p.identity.clone()))).collect::<Result<_>>()?;
|
||||
|
||||
match entry.kind {
|
||||
JournalKind::Import => {
|
||||
bind_in_tx(&tx, entry.character_id, &entry.server_id, allocation.local_guid, entry.revision, MappingState::Synced)?;
|
||||
let observations: Vec<ItemObservation> = item_rows.iter().map(|(id, guid, entry, identity)| ItemObservation { portable_item_id: *id, local_item_guid: *guid, entry: entry.clone(), identity: identity.clone() }).collect();
|
||||
reconcile_in_tx(&tx, entry.character_id, &entry.server_id, entry.revision, &observations)?;
|
||||
let pets: Vec<PetObservation> = pet_rows.iter().map(|(id, number, identity)| PetObservation { portable_pet_id: *id, local_pet_number: *number, identity: identity.clone() }).collect();
|
||||
sync_pets_in_tx(&tx, entry.character_id, &entry.server_id, entry.revision, &pets, &PetProtection::default())?;
|
||||
}
|
||||
JournalKind::Update => {
|
||||
let local: i64 = tx.query_row("SELECT local_guid FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2", params![entry.character_id.to_string(), entry.server_id], |r| r.get(0))?;
|
||||
if local != allocation.local_guid as i64 {
|
||||
return Err(PortableError::Invalid(format!("the update reports local character {} but the realm binding is {local}", allocation.local_guid)));
|
||||
}
|
||||
retire_items_by_id(&tx, entry.character_id, &entry.server_id, &entry.retired_items, entry.revision)?;
|
||||
retire_pets_by_id(&tx, entry.character_id, &entry.server_id, &entry.retired_pets, entry.revision)?;
|
||||
add_item_mappings(&tx, entry.character_id, &entry.server_id, entry.revision, &item_rows)?;
|
||||
add_pet_mappings(&tx, entry.character_id, &entry.server_id, entry.revision, &pet_rows)?;
|
||||
tx.execute(
|
||||
"UPDATE character_server_mapping SET last_revision = ?3, state = 'synced', updated_at = ?4 WHERE character_id = ?1 AND server_id = ?2",
|
||||
params![entry.character_id.to_string(), entry.server_id, entry.revision as i64, now()],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
bind_in_tx(&tx, entry.character_id, &entry.server_id, allocation.local_guid, entry.revision, MappingState::Synced)?;
|
||||
reconcile_in_tx(&tx, entry.character_id, &entry.server_id, entry.revision, &observations)?;
|
||||
let (hash, payload): (Vec<u8>, Vec<u8>) = tx.query_row("SELECT target_hash, target_payload FROM import_journal WHERE import_id = ?1", [import_id.to_string()], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
let target = snapshot::decode(&payload, Some(&hash_from_blob(hash)?))?;
|
||||
set_synced_in_tx(&tx, entry.character_id, &entry.server_id, &snapshot::encode(&target)?)?;
|
||||
tx.execute(
|
||||
"UPDATE import_journal SET state = 'committed', local_guid = ?2, item_base = ?3, pet_base = ?4, updated_at = ?5 WHERE import_id = ?1",
|
||||
params![import_id.to_string(), allocation.local_guid, allocation.item_base, allocation.pet_base, now()],
|
||||
@@ -188,7 +290,7 @@ impl Store {
|
||||
read_journal(&self.conn, import_id)
|
||||
}
|
||||
|
||||
/// Unfinished imports into one server, oldest first.
|
||||
/// Unfinished imports and updates into one server, oldest first.
|
||||
pub fn open_imports(&self, server_id: &str) -> Result<Vec<JournalEntry>> {
|
||||
let mut stmt = self.conn.prepare("SELECT import_id FROM import_journal WHERE server_id = ?1 AND state = 'prepared' ORDER BY import_id")?;
|
||||
let ids: Vec<String> = stmt.query_map([server_id], |r| r.get(0))?.collect::<std::result::Result<_, _>>()?;
|
||||
@@ -197,15 +299,16 @@ impl Store {
|
||||
}
|
||||
|
||||
fn read_journal(conn: &Connection, import_id: ImportId) -> Result<JournalEntry> {
|
||||
type Row = (String, String, i64, String, String, String, String, Option<i64>, Option<i64>, Option<i64>, Option<String>, String, String);
|
||||
type Row = (String, String, i64, String, String, String, String, Option<i64>, Option<i64>, Option<i64>, Option<String>, String, String, String, String, String);
|
||||
let row: Option<Row> = conn
|
||||
.query_row(
|
||||
"SELECT character_id, server_id, revision, marker, state, items, pet_ids, local_guid, item_base, pet_base, detail, created_at, updated_at FROM import_journal WHERE import_id = ?1",
|
||||
"SELECT character_id, server_id, revision, marker, state, items, pet_ids, local_guid, item_base, pet_base, detail, created_at, updated_at, kind, retired_items, retired_pets
|
||||
FROM import_journal WHERE import_id = ?1",
|
||||
[import_id.to_string()],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?, r.get(10)?, r.get(11)?, r.get(12)?)),
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?, r.get(8)?, r.get(9)?, r.get(10)?, r.get(11)?, r.get(12)?, r.get(13)?, r.get(14)?, r.get(15)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((character, server_id, revision, marker, state, items, pets, guid, item_base, pet_base, detail, created_at, updated_at)) = row else {
|
||||
let Some((character, server_id, revision, marker, state, items, pets, guid, item_base, pet_base, detail, created_at, updated_at, kind, retired_items, retired_pets)) = row else {
|
||||
return Err(PortableError::UnknownImport(import_id));
|
||||
};
|
||||
let allocation = match (guid, item_base, pet_base) {
|
||||
@@ -214,13 +317,16 @@ fn read_journal(conn: &Connection, import_id: ImportId) -> Result<JournalEntry>
|
||||
};
|
||||
Ok(JournalEntry {
|
||||
import_id,
|
||||
kind: JournalKind::parse(&kind)?,
|
||||
character_id: character.parse()?,
|
||||
server_id,
|
||||
revision: revision as u64,
|
||||
marker,
|
||||
state: ImportState::parse(&state)?,
|
||||
items: serde_json::from_str(&items)?,
|
||||
pet_ids: serde_json::from_str(&pets)?,
|
||||
pets: serde_json::from_str(&pets)?,
|
||||
retired_items: serde_json::from_str(&retired_items)?,
|
||||
retired_pets: serde_json::from_str(&retired_pets)?,
|
||||
allocation,
|
||||
detail,
|
||||
created_at,
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
//! What the store remembers between the sessions of one character on one realm (Phase 4):
|
||||
//!
|
||||
//! * stable **pet mappings** (portable pet id <-> the realm's pet number), with the same lifecycle and content identity as
|
||||
//! item mappings, so a recycled pet number never inherits an old pet;
|
||||
//! * the **synced snapshot** of a realm: the canonical state the realm was last brought to, the *base* of the next in-place
|
||||
//! update;
|
||||
//! * the **session baseline**: `C0` (canonical at join) and `B0` (the realm after its own first load/save, before any
|
||||
//! progression), persisted so a reconciliation survives restarts and history pruning;
|
||||
//! * `commit_reconciled`: the one local transaction that turns a reconciled character into the next canonical revision.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::*;
|
||||
use crate::portable::ids::PortablePetId;
|
||||
|
||||
// ---- pets ---------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// What a realm currently holds for one pet of a character.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PetObservation {
|
||||
pub portable_pet_id: PortablePetId,
|
||||
pub local_pet_number: u32,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// What cannot change during a pet's life: its creature, kind and the spell that summoned it. The name, level and spells
|
||||
/// change all the time.
|
||||
pub fn pet_identity(entry: &ContentId, pet_type: u8, created_by_spell: u32) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(b"pet-v1\0");
|
||||
h.update(entry.to_string().as_bytes());
|
||||
h.update([0, pet_type]);
|
||||
h.update(created_by_spell.to_be_bytes());
|
||||
format!("pet-v1:{}", hex::encode(&h.finalize()[..16]))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PetMapping {
|
||||
pub portable_pet_id: PortablePetId,
|
||||
pub local_pet_number: u32,
|
||||
pub identity: String,
|
||||
pub active: bool,
|
||||
pub presence: Presence,
|
||||
pub created_revision: u64,
|
||||
pub confirmed_revision: u64,
|
||||
pub retired_revision: Option<u64>,
|
||||
pub retired_reason: Option<RetireReason>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PetProtection {
|
||||
pub canonical_pets: HashSet<PortablePetId>,
|
||||
pub realm_local_pets: HashSet<PortablePetId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PetSyncReport {
|
||||
pub confirmed: usize,
|
||||
pub added: usize,
|
||||
pub number_reused: usize,
|
||||
pub moved: usize,
|
||||
pub absent: usize,
|
||||
pub filtered: usize,
|
||||
}
|
||||
|
||||
fn pet_presence(protection: &PetProtection, id: PortablePetId) -> Presence {
|
||||
if protection.realm_local_pets.contains(&id) {
|
||||
Presence::RealmLocal
|
||||
} else {
|
||||
Presence::Present
|
||||
}
|
||||
}
|
||||
|
||||
fn retire_pet(tx: &Transaction<'_>, mapping_id: i64, reason: RetireReason, revision: u64, at: &str) -> Result<()> {
|
||||
tx.execute(
|
||||
"UPDATE pet_mapping SET state = 'retired', retired_revision = ?1, retired_reason = ?2, updated_at = ?3 WHERE mapping_id = ?4",
|
||||
params![revision as i64, reason.as_str(), at, mapping_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn retire_all_pets(tx: &Transaction<'_>, id: CharacterId, server_id: &str, reason: RetireReason, revision: u64) -> Result<usize> {
|
||||
Ok(tx.execute(
|
||||
"UPDATE pet_mapping SET state = 'retired', retired_revision = ?1, retired_reason = ?2, updated_at = ?3 WHERE character_id = ?4 AND server_id = ?5 AND state = 'active'",
|
||||
params![revision as i64, reason.as_str(), now(), id.to_string(), server_id],
|
||||
)?)
|
||||
}
|
||||
|
||||
/// The pet twin of `reconcile_with`: bring the pet mappings in line with what the realm shows.
|
||||
pub(super) fn sync_pets_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revision: u64, observations: &[PetObservation], protection: &PetProtection) -> Result<PetSyncReport> {
|
||||
let mut seen_pets = HashSet::new();
|
||||
let mut seen_numbers = HashSet::new();
|
||||
for o in observations {
|
||||
if !seen_pets.insert(o.portable_pet_id) || !seen_numbers.insert(o.local_pet_number) || o.identity.is_empty() {
|
||||
return Err(PortableError::Invalid("pet observations must be unique and carry an identity".into()));
|
||||
}
|
||||
}
|
||||
let at = now();
|
||||
let mut report = PetSyncReport::default();
|
||||
for o in observations {
|
||||
let by_number: Option<(i64, String, String)> = tx
|
||||
.query_row(
|
||||
"SELECT mapping_id, portable_pet_id, identity FROM pet_mapping WHERE character_id = ?1 AND server_id = ?2 AND local_pet_number = ?3 AND state = 'active'",
|
||||
params![id.to_string(), server_id, o.local_pet_number],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((mapping_id, pet, identity)) = &by_number {
|
||||
if *pet == o.portable_pet_id.to_string() && *identity == o.identity {
|
||||
tx.execute("UPDATE pet_mapping SET confirmed_revision = ?1, updated_at = ?2, presence = ?4 WHERE mapping_id = ?3", params![revision as i64, at, mapping_id, pet_presence(protection, o.portable_pet_id).as_str()])?;
|
||||
report.confirmed += 1;
|
||||
continue;
|
||||
}
|
||||
retire_pet(tx, *mapping_id, RetireReason::GuidReused, revision, &at)?;
|
||||
report.number_reused += 1;
|
||||
}
|
||||
let by_pet: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT mapping_id FROM pet_mapping WHERE character_id = ?1 AND server_id = ?2 AND portable_pet_id = ?3 AND state = 'active'",
|
||||
params![id.to_string(), server_id, o.portable_pet_id.to_string()],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(mapping_id) = by_pet {
|
||||
retire_pet(tx, mapping_id, RetireReason::Moved, revision, &at)?;
|
||||
report.moved += 1;
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO pet_mapping(character_id, server_id, portable_pet_id, local_pet_number, identity, state, presence, created_revision, confirmed_revision, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'active', ?6, ?7, ?7, ?8, ?8)",
|
||||
params![id.to_string(), server_id, o.portable_pet_id.to_string(), o.local_pet_number, o.identity, pet_presence(protection, o.portable_pet_id).as_str(), revision as i64, at],
|
||||
)?;
|
||||
report.added += 1;
|
||||
}
|
||||
let mut stmt = tx.prepare("SELECT mapping_id, portable_pet_id FROM pet_mapping WHERE character_id = ?1 AND server_id = ?2 AND state = 'active'")?;
|
||||
let active: Vec<(i64, String)> = stmt.query_map(params![id.to_string(), server_id], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<std::result::Result<_, _>>()?;
|
||||
drop(stmt);
|
||||
for (mapping_id, pet) in active {
|
||||
if observations.iter().any(|o| o.portable_pet_id.to_string() == pet) {
|
||||
continue;
|
||||
}
|
||||
let owned = pet.parse::<PortablePetId>().map(|p| protection.canonical_pets.contains(&p)).unwrap_or(false);
|
||||
if owned {
|
||||
tx.execute("UPDATE pet_mapping SET presence = 'filtered', updated_at = ?2 WHERE mapping_id = ?1", params![mapping_id, at])?;
|
||||
report.filtered += 1;
|
||||
} else {
|
||||
retire_pet(tx, mapping_id, RetireReason::Absent, revision, &at)?;
|
||||
report.absent += 1;
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Mappings for pets that are new on a realm (an import or an update added them): no observation of the others.
|
||||
pub(super) fn add_pet_mappings(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revision: u64, pets: &[(PortablePetId, u32, String)]) -> Result<()> {
|
||||
let at = now();
|
||||
for (pet, number, identity) in pets {
|
||||
tx.execute(
|
||||
"INSERT INTO pet_mapping(character_id, server_id, portable_pet_id, local_pet_number, identity, state, presence, created_revision, confirmed_revision, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 'active', 'present', ?6, ?6, ?7, ?7)",
|
||||
params![id.to_string(), server_id, pet.to_string(), number, identity, revision as i64, at],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn retire_pets_by_id(tx: &Transaction<'_>, id: CharacterId, server_id: &str, pets: &[PortablePetId], revision: u64) -> Result<()> {
|
||||
let at = now();
|
||||
for pet in pets {
|
||||
tx.execute(
|
||||
"UPDATE pet_mapping SET state = 'retired', retired_revision = ?1, retired_reason = 'absent', updated_at = ?2
|
||||
WHERE character_id = ?3 AND server_id = ?4 AND portable_pet_id = ?5 AND state = 'active'",
|
||||
params![revision as i64, at, id.to_string(), server_id, pet.to_string()],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn retire_items_by_id(tx: &Transaction<'_>, id: CharacterId, server_id: &str, items: &[PortableItemId], revision: u64) -> Result<()> {
|
||||
let at = now();
|
||||
for item in items {
|
||||
tx.execute(
|
||||
"UPDATE item_mapping SET state = 'retired', retired_revision = ?1, retired_reason = 'absent', updated_at = ?2
|
||||
WHERE character_id = ?3 AND server_id = ?4 AND portable_item_id = ?5 AND state = 'active'",
|
||||
params![revision as i64, at, id.to_string(), server_id, item.to_string()],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Item mappings for items that are new on a realm.
|
||||
pub(super) fn add_item_mappings(tx: &Transaction<'_>, id: CharacterId, server_id: &str, revision: u64, items: &[(PortableItemId, u32, ContentId, String)]) -> Result<()> {
|
||||
let at = now();
|
||||
for (item, guid, entry, identity) in items {
|
||||
tx.execute(
|
||||
"INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid, entry, identity, state, presence, created_revision, confirmed_revision, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', 'present', ?7, ?7, ?8, ?8)",
|
||||
params![id.to_string(), server_id, item.to_string(), guid, entry.to_string(), identity, revision as i64, at],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_pet_mappings(conn: &Connection, id: CharacterId, server_id: &str, include_retired: bool) -> Result<Vec<PetMapping>> {
|
||||
let sql = format!(
|
||||
"SELECT portable_pet_id, local_pet_number, identity, state, presence, created_revision, confirmed_revision, retired_revision, retired_reason FROM pet_mapping
|
||||
WHERE character_id = ?1 AND server_id = ?2 {} ORDER BY mapping_id",
|
||||
if include_retired { "" } else { "AND state = 'active'" }
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![id.to_string(), server_id], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?, r.get::<_, i64>(5)?, r.get::<_, i64>(6)?, r.get::<_, Option<i64>>(7)?, r.get::<_, Option<String>>(8)?))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (pet, number, identity, state, presence, created, confirmed, retired, reason) = row?;
|
||||
out.push(PetMapping {
|
||||
portable_pet_id: pet.parse()?,
|
||||
local_pet_number: number as u32,
|
||||
identity,
|
||||
active: state == "active",
|
||||
presence: Presence::parse(&presence)?,
|
||||
created_revision: created as u64,
|
||||
confirmed_revision: confirmed as u64,
|
||||
retired_revision: retired.map(|r| r as u64),
|
||||
retired_reason: reason.as_deref().map(RetireReason::parse).transpose()?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ---- synced snapshot ----------------------------------------------------------------------------------------------------------
|
||||
|
||||
pub(super) fn set_synced_in_tx(tx: &Transaction<'_>, id: CharacterId, server_id: &str, encoded: &EncodedSnapshot) -> Result<()> {
|
||||
let changed = tx.execute(
|
||||
"UPDATE character_server_mapping SET synced_hash = ?3, synced_payload = ?4, updated_at = ?5 WHERE character_id = ?1 AND server_id = ?2",
|
||||
params![id.to_string(), server_id, encoded.content_hash.as_slice(), encoded.payload, now()],
|
||||
)?;
|
||||
if changed == 0 {
|
||||
return Err(PortableError::Invalid(format!("character {id} is not bound to server {server_id}")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- baselines ------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
/// The persisted base of a session reconciliation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Baseline {
|
||||
pub character_id: CharacterId,
|
||||
pub server_id: String,
|
||||
/// The canonical revision at join.
|
||||
pub c0_revision: u64,
|
||||
/// The canonical revision this session has produced so far.
|
||||
pub head_revision: u64,
|
||||
/// Canonical at join.
|
||||
pub c0: PortableCharacter,
|
||||
/// The realm after its own first load/save normalisation, before any progression.
|
||||
pub b0: PortableCharacter,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// What the realm showed at the baseline.
|
||||
pub struct BaselineInput<'a> {
|
||||
pub b0: &'a PortableCharacter,
|
||||
pub items: &'a [ItemObservation],
|
||||
pub pets: &'a [PetObservation],
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn sync_pet_mappings(&mut self, id: CharacterId, server_id: &str, revision: u64, observations: &[PetObservation], protection: &PetProtection) -> Result<PetSyncReport> {
|
||||
check_server_id(server_id)?;
|
||||
let tx = self.write_tx()?;
|
||||
let report = sync_pets_in_tx(&tx, id, server_id, revision, observations, protection)?;
|
||||
tx.commit()?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn active_pet_lookup(&self, id: CharacterId, server_id: &str) -> Result<HashMap<u32, (PortablePetId, String)>> {
|
||||
Ok(read_pet_mappings(&self.conn, id, server_id, false)?.into_iter().map(|m| (m.local_pet_number, (m.portable_pet_id, m.identity))).collect())
|
||||
}
|
||||
|
||||
pub fn pet_mappings(&self, id: CharacterId, server_id: &str) -> Result<Vec<PetMapping>> {
|
||||
read_pet_mappings(&self.conn, id, server_id, false)
|
||||
}
|
||||
|
||||
pub fn pet_mapping_history(&self, id: CharacterId, server_id: &str) -> Result<Vec<PetMapping>> {
|
||||
read_pet_mappings(&self.conn, id, server_id, true)
|
||||
}
|
||||
|
||||
/// The canonical snapshot a realm was last synchronised with: the base of the next in-place update.
|
||||
pub fn synced_model(&self, id: CharacterId, server_id: &str) -> Result<Option<PortableCharacter>> {
|
||||
let row: Option<(Option<Vec<u8>>, Option<Vec<u8>>)> = self
|
||||
.conn
|
||||
.query_row("SELECT synced_hash, synced_payload FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id], |r| Ok((r.get(0)?, r.get(1)?)))
|
||||
.optional()?;
|
||||
match row {
|
||||
Some((Some(hash), Some(payload))) => Ok(Some(snapshot::decode(&payload, Some(&hash_from_blob(hash)?))?)),
|
||||
Some(_) => Ok(None),
|
||||
None => Err(PortableError::Invalid(format!("character {id} is not bound to server {server_id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the baseline of a session: `C0` (the snapshot the realm was synchronised with) and `B0` (what the realm shows
|
||||
/// after its own normalisation). Classifies the realm's items and pets: owned by the character but not shown by the realm
|
||||
/// = **filtered** (kept, mapping stays active); shown by the realm but not owned = **realm-local** (never merged).
|
||||
pub fn capture_baseline(&mut self, id: CharacterId, server_id: &str, input: BaselineInput<'_>) -> Result<Baseline> {
|
||||
check_server_id(server_id)?;
|
||||
let c0 = self.synced_model(id, server_id)?.ok_or_else(|| PortableError::Invalid("the realm has no synchronised snapshot to use as the session base".into()))?;
|
||||
if input.b0.character_id != id || input.b0.ruleset != c0.ruleset {
|
||||
return Err(PortableError::WrongCharacter { expected: id, found: input.b0.character_id });
|
||||
}
|
||||
let tx = self.write_tx()?;
|
||||
let record = read_character(&tx, id)?;
|
||||
let mapping_revision: i64 = tx.query_row("SELECT last_revision FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id], |r| r.get(0))?;
|
||||
if record.revision != mapping_revision as u64 {
|
||||
return Err(PortableError::StaleRevision { expected: mapping_revision as u64, current: record.revision });
|
||||
}
|
||||
let open: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM realm_baseline WHERE character_id = ?1 AND server_id = ?2 AND state = 'open')", params![id.to_string(), server_id], |r| r.get(0))?;
|
||||
if open {
|
||||
return Err(PortableError::Invalid("this character already has an open session baseline on this realm".into()));
|
||||
}
|
||||
let c0_enc = snapshot::encode(&c0)?;
|
||||
let b0_enc = snapshot::encode(input.b0)?;
|
||||
let c0_items: HashSet<PortableItemId> = c0.items.iter().map(|i| i.id).collect();
|
||||
let c0_pets: HashSet<PortablePetId> = c0.pets.iter().map(|p| p.id).collect();
|
||||
let protection = Protection { realm_local_items: input.items.iter().map(|o| o.portable_item_id).filter(|i| !c0_items.contains(i)).collect(), canonical_items: c0_items };
|
||||
reconcile_with(&tx, id, server_id, record.revision, input.items, &protection)?;
|
||||
let pet_protection = PetProtection { realm_local_pets: input.pets.iter().map(|o| o.portable_pet_id).filter(|p| !c0_pets.contains(p)).collect(), canonical_pets: c0_pets };
|
||||
sync_pets_in_tx(&tx, id, server_id, record.revision, input.pets, &pet_protection)?;
|
||||
let at = now();
|
||||
tx.execute(
|
||||
"INSERT INTO realm_baseline(character_id, server_id, c0_revision, head_revision, state, c0_hash, c0_payload, b0_hash, b0_payload, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?3, 'open', ?4, ?5, ?6, ?7, ?8, ?8)",
|
||||
params![id.to_string(), server_id, record.revision as i64, c0_enc.content_hash.as_slice(), c0_enc.payload, b0_enc.content_hash.as_slice(), b0_enc.payload, at],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(Baseline { character_id: id, server_id: server_id.to_string(), c0_revision: record.revision, head_revision: record.revision, c0, b0: input.b0.clone(), created_at: at })
|
||||
}
|
||||
|
||||
pub fn open_baseline(&self, id: CharacterId, server_id: &str) -> Result<Option<Baseline>> {
|
||||
read_open_baseline(&self.conn, id, server_id)
|
||||
}
|
||||
|
||||
/// End the session: the baseline is kept for the record but no longer used.
|
||||
pub fn close_baseline(&mut self, id: CharacterId, server_id: &str) -> Result<bool> {
|
||||
let changed = self.conn.execute(
|
||||
"UPDATE realm_baseline SET state = 'closed', updated_at = ?3 WHERE character_id = ?1 AND server_id = ?2 AND state = 'open'",
|
||||
params![id.to_string(), server_id, now()],
|
||||
)?;
|
||||
Ok(changed > 0)
|
||||
}
|
||||
|
||||
/// Turn a reconciled character into the next canonical revision, in **one** local transaction: the new snapshot, the
|
||||
/// session head, the realm's item/pet mappings as of `B1`, and the realm's synced snapshot.
|
||||
/// The canonical character must still be exactly what this session produced last (no foreign progress in between).
|
||||
pub fn commit_reconciled(&mut self, id: CharacterId, server_id: &str, merged: PortableCharacter, b1_items: &[ItemObservation], b1_pets: &[PetObservation], note: Option<&str>) -> Result<u64> {
|
||||
check_server_id(server_id)?;
|
||||
if merged.character_id != id {
|
||||
return Err(PortableError::WrongCharacter { expected: id, found: merged.character_id });
|
||||
}
|
||||
let encoded = snapshot::encode(&merged)?;
|
||||
let keep = self.history_keep()?;
|
||||
let tx = self.write_tx()?;
|
||||
let baseline = read_open_baseline(&tx, id, server_id)?.ok_or_else(|| PortableError::Invalid("there is no open session baseline for this character on this realm".into()))?;
|
||||
let record = read_character(&tx, id)?;
|
||||
if record.ruleset != merged.ruleset {
|
||||
return Err(PortableError::RulesetChange { from: record.ruleset.to_string(), to: merged.ruleset.to_string() });
|
||||
}
|
||||
if record.revision != baseline.head_revision {
|
||||
return Err(PortableError::StaleRevision { expected: baseline.head_revision, current: record.revision });
|
||||
}
|
||||
let at = now();
|
||||
// a session that changed nothing (or only what the realm normalised) makes no new revision
|
||||
let head_hash: Vec<u8> = tx.query_row("SELECT content_hash FROM snapshot WHERE character_id = ?1 AND revision = ?2", params![id.to_string(), record.revision as i64], |r| r.get(0))?;
|
||||
let revision = if head_hash.as_slice() == encoded.content_hash.as_slice() {
|
||||
record.revision
|
||||
} else {
|
||||
let revision = record.revision + 1;
|
||||
insert_snapshot(&tx, id, revision, &encoded, server_id, note, &at)?;
|
||||
update_character(&tx, id, &merged, revision, &at)?;
|
||||
prune_tx(&tx, id, revision, keep)?;
|
||||
tx.execute(
|
||||
"UPDATE realm_baseline SET head_revision = ?4, updated_at = ?5 WHERE character_id = ?1 AND server_id = ?2 AND c0_revision = ?3",
|
||||
params![id.to_string(), server_id, baseline.c0_revision as i64, revision as i64, at],
|
||||
)?;
|
||||
revision
|
||||
};
|
||||
|
||||
let owned_items: HashSet<PortableItemId> = merged.items.iter().map(|i| i.id).collect();
|
||||
let protection = Protection { realm_local_items: b1_items.iter().map(|o| o.portable_item_id).filter(|i| !owned_items.contains(i)).collect(), canonical_items: owned_items };
|
||||
reconcile_with(&tx, id, server_id, revision, b1_items, &protection)?;
|
||||
let owned_pets: HashSet<PortablePetId> = merged.pets.iter().map(|p| p.id).collect();
|
||||
let pet_protection = PetProtection { realm_local_pets: b1_pets.iter().map(|o| o.portable_pet_id).filter(|p| !owned_pets.contains(p)).collect(), canonical_pets: owned_pets };
|
||||
sync_pets_in_tx(&tx, id, server_id, revision, b1_pets, &pet_protection)?;
|
||||
|
||||
tx.execute(
|
||||
"UPDATE character_server_mapping SET last_revision = ?3, state = 'active', updated_at = ?4 WHERE character_id = ?1 AND server_id = ?2",
|
||||
params![id.to_string(), server_id, revision as i64, at],
|
||||
)?;
|
||||
set_synced_in_tx(&tx, id, server_id, &encoded)?;
|
||||
tx.commit()?;
|
||||
Ok(revision)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_open_baseline(conn: &Connection, id: CharacterId, server_id: &str) -> Result<Option<Baseline>> {
|
||||
type Row = (i64, i64, Vec<u8>, Vec<u8>, Vec<u8>, Vec<u8>, String);
|
||||
let row: Option<Row> = conn
|
||||
.query_row(
|
||||
"SELECT c0_revision, head_revision, c0_hash, c0_payload, b0_hash, b0_payload, created_at FROM realm_baseline WHERE character_id = ?1 AND server_id = ?2 AND state = 'open'",
|
||||
params![id.to_string(), server_id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((c0_revision, head, c0_hash, c0_payload, b0_hash, b0_payload, created_at)) = row else { return Ok(None) };
|
||||
Ok(Some(Baseline {
|
||||
character_id: id,
|
||||
server_id: server_id.to_string(),
|
||||
c0_revision: c0_revision as u64,
|
||||
head_revision: head as u64,
|
||||
c0: snapshot::decode(&c0_payload, Some(&hash_from_blob(c0_hash)?))?,
|
||||
b0: snapshot::decode(&b0_payload, Some(&hash_from_blob(b0_hash)?))?,
|
||||
created_at,
|
||||
}))
|
||||
}
|
||||
@@ -446,7 +446,7 @@ fn moved_and_absent_items_are_retired_with_their_reason() {
|
||||
|
||||
// item 0 got a new local guid (re-imported), item 1 is gone, item 2 is unchanged
|
||||
let report = store.reconcile_item_mappings(id, "realm-a", 1, &[obs(items[0], 200, 1, "v1:a"), obs(items[2], 102, 3, "v1:c")]).unwrap();
|
||||
assert_eq!(report, ReconcileReport { confirmed: 1, added: 1, guid_reused: 0, moved: 1, absent: 1 });
|
||||
assert_eq!(report, ReconcileReport { confirmed: 1, added: 1, guid_reused: 0, moved: 1, absent: 1, filtered: 0 });
|
||||
|
||||
let active: Vec<(PortableItemId, u32)> = store.item_mappings(id, "realm-a").unwrap().into_iter().map(|m| (m.portable_item_id, m.local_item_guid)).collect();
|
||||
assert_eq!(active, vec![(items[2], 102), (items[0], 200)]);
|
||||
@@ -485,26 +485,19 @@ fn schema_1_item_mappings_survive_the_migration_as_unverified() {
|
||||
let file = Store::path_of(dir.path());
|
||||
let (character, item);
|
||||
{
|
||||
// build a schema-1 database by hand, exactly as Phase 1 wrote it
|
||||
let mut store = Store::open_file(&file).unwrap();
|
||||
let profile = store.default_profile().unwrap();
|
||||
character = created(&mut store, profile);
|
||||
item = store.load_current(character).unwrap().items[0].id;
|
||||
store.bind_server(character, "realm-a", 154, 1, MappingState::Active).unwrap();
|
||||
store.conn.execute_batch(
|
||||
"DROP TABLE import_journal;
|
||||
DROP TABLE item_mapping;
|
||||
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;
|
||||
PRAGMA user_version = 1;",
|
||||
)
|
||||
.unwrap();
|
||||
store.conn.execute(
|
||||
"INSERT INTO item_mapping VALUES (?1, 'realm-a', ?2, 4242)",
|
||||
params![character.to_string(), item.to_string()],
|
||||
)
|
||||
.unwrap();
|
||||
// a schema-1 database, exactly as Phase 1 wrote it: only the first migration applied
|
||||
let conn = Connection::open(&file).unwrap();
|
||||
conn.pragma_update(None, "foreign_keys", true).unwrap();
|
||||
conn.execute_batch(MIGRATIONS[0]).unwrap();
|
||||
conn.pragma_update(None, "application_id", APPLICATION_ID).unwrap();
|
||||
conn.pragma_update(None, "user_version", 1).unwrap();
|
||||
let profile = ProfileId::new();
|
||||
character = CharacterId::new();
|
||||
item = PortableItemId::new();
|
||||
conn.execute("INSERT INTO profile VALUES (?1, 'x', 1)", [profile.to_string()]).unwrap();
|
||||
conn.execute("INSERT INTO character VALUES (?1, ?2, 'coa', 'n', 'r', 'c', 0, 1, 1, 'x', 'x', 0)", params![character.to_string(), profile.to_string()]).unwrap();
|
||||
conn.execute("INSERT INTO character_server_mapping VALUES (?1, 'realm-a', 154, 1, 'active', 'x')", [character.to_string()]).unwrap();
|
||||
conn.execute("INSERT INTO item_mapping VALUES (?1, 'realm-a', ?2, 4242)", params![character.to_string(), item.to_string()]).unwrap();
|
||||
}
|
||||
let store = Store::open_file(&file).unwrap();
|
||||
assert_eq!(store.schema_version().unwrap(), MIGRATIONS.len() as i64);
|
||||
@@ -592,6 +585,10 @@ fn plan_of_current(store: &Store, id: CharacterId) -> Vec<PlannedItem> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn planned_pets_of(store: &Store, id: CharacterId) -> Vec<PlannedPet> {
|
||||
store.load_current(id).unwrap().pets.iter().map(|p| PlannedPet { id: p.id, entry: p.entry.clone(), identity: pet_identity(&p.entry, p.pet_type, p.created_by_spell) }).collect()
|
||||
}
|
||||
|
||||
fn alloc(guid: u32) -> ImportAllocation {
|
||||
ImportAllocation { local_guid: guid, item_base: 20_064, pet_base: 3_004 }
|
||||
}
|
||||
@@ -601,12 +598,12 @@ fn an_import_is_journaled_first_and_mapped_only_when_finished() {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let items = plan_of_current(&store, id);
|
||||
let pets = store.load_current(id).unwrap().pets.iter().map(|p| p.id).collect::<Vec<_>>();
|
||||
let pets = planned_pets_of(&store, id);
|
||||
|
||||
let ticket = store.begin_import(id, "realm-b", 1, &items, &pets).unwrap();
|
||||
assert_eq!(ticket.marker, marker_text(ticket.nonce, 1));
|
||||
let entry = store.import_entry(ticket.import_id).unwrap();
|
||||
assert_eq!((entry.state, entry.revision, entry.items.len(), entry.pet_ids.len()), (ImportState::Prepared, 1, items.len(), pets.len()));
|
||||
assert_eq!((entry.state, entry.revision, entry.items.len(), entry.pets.len()), (ImportState::Prepared, 1, items.len(), pets.len()));
|
||||
// nothing is mapped before the realm has committed
|
||||
assert!(store.server_mappings(id).unwrap().is_empty());
|
||||
assert_eq!(store.find_by_local("realm-b", 1013).unwrap(), None);
|
||||
@@ -694,3 +691,277 @@ fn a_failing_finish_changes_nothing_locally() {
|
||||
assert!(store.server_mappings(id).unwrap().is_empty());
|
||||
assert!(store.item_mappings(id, "realm-b").unwrap().is_empty());
|
||||
}
|
||||
|
||||
// ---- Phase 4: presence, pet mappings, baselines, reconciliation, updates ---------------------------------------------------------
|
||||
|
||||
use crate::portable::ids::PortablePetId;
|
||||
use crate::portable::merge::{merge3, Mode};
|
||||
use crate::portable::model::PortableItem;
|
||||
|
||||
const ITEM_BASE: u32 = 20_064;
|
||||
const PET_BASE: u32 = 3_004;
|
||||
|
||||
/// The character joined `realm-b` by an import and is synced there at revision 1.
|
||||
fn joined() -> (Store, CharacterId, PortableCharacter) {
|
||||
let (mut store, profile) = fresh();
|
||||
let id = created(&mut store, profile);
|
||||
let items = plan_of_current(&store, id);
|
||||
let pets = planned_pets_of(&store, id);
|
||||
let ticket = store.begin_import(id, "realm-b", 1, &items, &pets).unwrap();
|
||||
store.finish_import(ticket.import_id, alloc(1013)).unwrap();
|
||||
let c0 = store.load_current(id).unwrap();
|
||||
(store, id, c0)
|
||||
}
|
||||
|
||||
fn pet_obs(m: &PortableCharacter) -> Vec<PetObservation> {
|
||||
m.pets.iter().enumerate().map(|(i, p)| PetObservation { portable_pet_id: p.id, local_pet_number: PET_BASE + i as u32, identity: pet_identity(&p.entry, p.pet_type, p.created_by_spell) }).collect()
|
||||
}
|
||||
|
||||
/// Items the realm would filter away: plain ones that hold nothing.
|
||||
fn filterable(m: &PortableCharacter, n: usize) -> Vec<PortableItemId> {
|
||||
let containers: std::collections::HashSet<PortableItemId> = m.items.iter().filter_map(|i| i.container).collect();
|
||||
m.items.iter().filter(|i| i.container.is_none() && !containers.contains(&i.id) && i.slot < 40).map(|i| i.id).take(n).collect()
|
||||
}
|
||||
|
||||
fn realm_item(n: u64) -> PortableItem {
|
||||
PortableItem {
|
||||
id: PortableItemId::from_uuid(fixtures::id7(600_000 + n)).unwrap(),
|
||||
container: None,
|
||||
slot: 60 + n as u8,
|
||||
entry: ContentId::new("coa", "item", 66_000 + n).unwrap(),
|
||||
count: 1,
|
||||
duration: 0,
|
||||
charges: vec![],
|
||||
flags: 0,
|
||||
enchantments: vec![],
|
||||
random_property_id: 0,
|
||||
durability: 10,
|
||||
played_time: 0,
|
||||
text: None,
|
||||
creator_name: None,
|
||||
gift: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the realm shows after its own first load/save: two items held back, one of its own added.
|
||||
fn b0_of(c0: &PortableCharacter) -> (PortableCharacter, Vec<PortableItemId>, PortableItemId) {
|
||||
let filtered = filterable(c0, 2);
|
||||
let mut b0 = c0.clone();
|
||||
b0.items.retain(|i| !filtered.contains(&i.id));
|
||||
let own = realm_item(1);
|
||||
let own_id = own.id;
|
||||
b0.items.push(own);
|
||||
b0.progression.honor.today_honor = 0;
|
||||
(b0.normalized(), filtered, own_id)
|
||||
}
|
||||
|
||||
/// The realm's item guids: the import's `ITEM_BASE + index` for imported items, `99_000 + n` for the realm's own.
|
||||
fn observed(c0: &PortableCharacter, model: &PortableCharacter) -> Vec<ItemObservation> {
|
||||
model
|
||||
.items
|
||||
.iter()
|
||||
.map(|it| ItemObservation {
|
||||
portable_item_id: it.id,
|
||||
local_item_guid: match c0.items.iter().position(|o| o.id == it.id) {
|
||||
Some(i) => ITEM_BASE + i as u32,
|
||||
None => 99_000 + (it.entry.id() % 1000) as u32,
|
||||
},
|
||||
entry: it.entry.clone(),
|
||||
identity: crate::portable::identity::item_identity(&it.entry, it.random_property_id),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_tells_filtered_items_from_the_realms_own_and_keeps_both_mapped() {
|
||||
let (mut store, id, c0) = joined();
|
||||
let (b0, filtered, own) = b0_of(&c0);
|
||||
|
||||
let baseline = store.capture_baseline(id, "realm-b", BaselineInput { b0: &b0, items: &observed(&c0, &b0), pets: &pet_obs(&b0) }).unwrap();
|
||||
assert_eq!((baseline.c0_revision, baseline.head_revision), (1, 1));
|
||||
assert_eq!(baseline.c0, c0, "C0 is the snapshot the realm was synced with");
|
||||
assert_eq!(baseline.b0, b0);
|
||||
|
||||
let mappings = store.item_mappings(id, "realm-b").unwrap();
|
||||
let presence = |item: PortableItemId| mappings.iter().find(|m| m.portable_item_id == item && m.active).map(|m| m.presence);
|
||||
for f in &filtered {
|
||||
assert_eq!(presence(*f), Some(Presence::Filtered), "an item the realm held back stays mapped, as filtered, not retired");
|
||||
}
|
||||
assert_eq!(presence(own), Some(Presence::RealmLocal), "an item the canonical character does not own is realm-local");
|
||||
assert_eq!(presence(c0.items.iter().find(|i| !filtered.contains(&i.id)).unwrap().id), Some(Presence::Present));
|
||||
assert!(mappings.iter().all(|m| m.active), "nothing was retired");
|
||||
|
||||
// the baseline is persisted and one per realm at a time
|
||||
let again = store.open_baseline(id, "realm-b").unwrap().unwrap();
|
||||
assert_eq!((again.c0, again.b0), (c0.clone(), b0.clone()));
|
||||
assert!(store.capture_baseline(id, "realm-b", BaselineInput { b0: &b0, items: &observed(&c0, &b0), pets: &pet_obs(&b0) }).is_err());
|
||||
assert!(store.close_baseline(id, "realm-b").unwrap());
|
||||
assert!(store.open_baseline(id, "realm-b").unwrap().is_none());
|
||||
assert!(!store.close_baseline(id, "realm-b").unwrap(), "closing twice is harmless");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baseline_needs_the_canonical_revision_the_realm_was_synced_with() {
|
||||
let (mut store, id, c0) = joined();
|
||||
let (b0, _, _) = b0_of(&c0);
|
||||
// canonical moved on (another realm's session): the base of this session would be wrong
|
||||
store.commit_snapshot(id, 1, with_money(c0.clone(), 5), "realm-a", None).unwrap();
|
||||
let r = store.capture_baseline(id, "realm-b", BaselineInput { b0: &b0, items: &observed(&c0, &b0), pets: &pet_obs(&b0) });
|
||||
assert!(matches!(r, Err(PortableError::StaleRevision { expected: 1, current: 2 })), "{r:?}");
|
||||
assert!(store.open_baseline(id, "realm-b").unwrap().is_none(), "a refused capture leaves nothing behind");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconciling_makes_one_new_revision_and_leaves_the_realms_state_in_sync() {
|
||||
let (mut store, id, c0) = joined();
|
||||
let (b0, filtered, own) = b0_of(&c0);
|
||||
store.capture_baseline(id, "realm-b", BaselineInput { b0: &b0, items: &observed(&c0, &b0), pets: &pet_obs(&b0) }).unwrap();
|
||||
|
||||
// the player earns money, loses a plain item and picks one up
|
||||
let sold = filterable(&b0, 6).into_iter().find(|i| !filtered.contains(i) && *i != own).unwrap();
|
||||
let mut b1 = b0.clone();
|
||||
b1.progression.money += 500;
|
||||
b1.items.retain(|i| i.id != sold);
|
||||
let loot = realm_item(2);
|
||||
let loot_id = loot.id;
|
||||
b1.items.push(loot);
|
||||
let b1 = b1.normalized();
|
||||
let baseline = store.open_baseline(id, "realm-b").unwrap().unwrap();
|
||||
let merged = merge3(&baseline.c0, &baseline.b0, &b1, Mode::Lenient).unwrap();
|
||||
|
||||
let revision = store.commit_reconciled(id, "realm-b", merged.model.clone(), &observed(&c0, &b1), &pet_obs(&b1), Some("session")).unwrap();
|
||||
assert_eq!(revision, 2);
|
||||
let canonical = store.load_current(id).unwrap();
|
||||
assert_eq!(canonical.progression.money, c0.progression.money + 500);
|
||||
assert!(canonical.items.iter().all(|i| i.id != sold), "what the player got rid of is gone");
|
||||
assert!(filtered.iter().all(|f| canonical.items.iter().any(|i| i.id == *f)), "what the realm held back is still canonical");
|
||||
assert!(canonical.items.iter().any(|i| i.id == loot_id), "what the player found is canonical now");
|
||||
assert!(canonical.items.iter().all(|i| i.id != own), "the realm's own item never becomes canonical");
|
||||
|
||||
let head = store.open_baseline(id, "realm-b").unwrap().unwrap();
|
||||
assert_eq!((head.c0_revision, head.head_revision), (1, 2));
|
||||
assert_eq!(store.synced_model(id, "realm-b").unwrap().unwrap(), canonical, "the realm is synced with what was just committed");
|
||||
assert_eq!(store.server_mappings(id).unwrap()[0].last_revision, 2);
|
||||
|
||||
let mappings = store.item_mappings(id, "realm-b").unwrap();
|
||||
let state = |item: PortableItemId| mappings.iter().find(|m| m.portable_item_id == item && m.active).map(|m| m.presence);
|
||||
assert_eq!(state(loot_id), Some(Presence::Present));
|
||||
assert_eq!(state(own), Some(Presence::RealmLocal));
|
||||
for f in &filtered {
|
||||
assert_eq!(state(*f), Some(Presence::Filtered));
|
||||
}
|
||||
assert!(state(sold).is_none(), "the sold item's mapping is retired");
|
||||
|
||||
// a checkpoint later: the WHOLE B0 -> B1' delta is applied to C0 again, so nothing is counted twice
|
||||
let mut b2 = b1.clone();
|
||||
b2.progression.money += 100;
|
||||
let b2 = b2.normalized();
|
||||
let merged = merge3(&baseline.c0, &baseline.b0, &b2, Mode::Lenient).unwrap();
|
||||
assert_eq!(merged.model.progression.money, c0.progression.money + 600);
|
||||
let revision = store.commit_reconciled(id, "realm-b", merged.model.clone(), &observed(&c0, &b2), &pet_obs(&b2), None).unwrap();
|
||||
assert_eq!(revision, 3);
|
||||
// a checkpoint with no new progress makes no new revision
|
||||
let again = store.commit_reconciled(id, "realm-b", merged.model, &observed(&c0, &b2), &pet_obs(&b2), None).unwrap();
|
||||
assert_eq!(again, 3);
|
||||
assert_eq!(store.list_revisions(id).unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reconcile_refuses_a_canonical_character_that_moved_under_it() {
|
||||
let (mut store, id, c0) = joined();
|
||||
let (b0, _, _) = b0_of(&c0);
|
||||
store.capture_baseline(id, "realm-b", BaselineInput { b0: &b0, items: &observed(&c0, &b0), pets: &pet_obs(&b0) }).unwrap();
|
||||
store.commit_snapshot(id, 1, with_money(c0.clone(), 9), "realm-a", None).unwrap();
|
||||
let mut b1 = b0.clone();
|
||||
b1.progression.money += 1;
|
||||
let merged = merge3(&c0, &b0, &b1, Mode::Lenient).unwrap();
|
||||
let r = store.commit_reconciled(id, "realm-b", merged.model.clone(), &observed(&c0, &b1), &pet_obs(&b1), None);
|
||||
assert!(matches!(r, Err(PortableError::StaleRevision { expected: 1, current: 2 })), "{r:?}");
|
||||
assert_eq!(store.character(id).unwrap().revision, 2, "nothing was committed");
|
||||
// without a baseline there is nothing to reconcile
|
||||
assert!(store.close_baseline(id, "realm-b").unwrap());
|
||||
assert!(store.commit_reconciled(id, "realm-b", merged.model, &observed(&c0, &b1), &pet_obs(&b1), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pet_mappings_are_stable_and_never_inherit_a_recycled_number() {
|
||||
let (mut store, id, c0) = joined();
|
||||
let pet = c0.pets[0].clone();
|
||||
let number = PET_BASE;
|
||||
let active = store.pet_mappings(id, "realm-b").unwrap();
|
||||
assert_eq!(active.len(), c0.pets.len());
|
||||
assert_eq!((active[0].portable_pet_id, active[0].local_pet_number, active[0].presence), (pet.id, number, Presence::Present));
|
||||
let lookup = store.active_pet_lookup(id, "realm-b").unwrap();
|
||||
assert_eq!(lookup[&number].0, pet.id, "the export reuses the id for the same pet number");
|
||||
|
||||
// the same pet seen again: confirmed, no new mapping
|
||||
let seen = pet_obs(&c0);
|
||||
let r = store.sync_pet_mappings(id, "realm-b", 1, &seen, &PetProtection::default()).unwrap();
|
||||
assert_eq!((r.confirmed, r.added), (seen.len(), 0));
|
||||
|
||||
// the realm recycles the number for another pet: the old mapping is retired, the newcomer is a new pet
|
||||
let stranger = PetObservation { portable_pet_id: PortablePetId::new(), local_pet_number: number, identity: "pet-v1:other".into() };
|
||||
let r = store.sync_pet_mappings(id, "realm-b", 1, &[stranger.clone()], &PetProtection { realm_local_pets: [stranger.portable_pet_id].into(), ..Default::default() }).unwrap();
|
||||
assert_eq!((r.number_reused, r.added), (1, 1));
|
||||
let history = store.pet_mapping_history(id, "realm-b").unwrap();
|
||||
let old = history.iter().find(|m| m.portable_pet_id == pet.id).unwrap();
|
||||
assert_eq!((old.active, old.retired_reason), (false, Some(RetireReason::GuidReused)));
|
||||
let new = history.iter().find(|m| m.portable_pet_id == stranger.portable_pet_id).unwrap();
|
||||
assert_eq!((new.active, new.presence), (true, Presence::RealmLocal));
|
||||
|
||||
// a stranger that left is retired; a pet the canonical character owns that the realm does not show would be filtered instead
|
||||
let r = store.sync_pet_mappings(id, "realm-b", 1, &[], &PetProtection { canonical_pets: c0.pets.iter().map(|p| p.id).collect(), ..Default::default() }).unwrap();
|
||||
assert_eq!(r.absent, 1, "the realm-local stranger is gone");
|
||||
assert!(store.pet_mappings(id, "realm-b").unwrap().iter().all(|m| m.portable_pet_id != stranger.portable_pet_id));
|
||||
// the same portable pet on a new number is a move
|
||||
let moved = PetObservation { portable_pet_id: pet.id, local_pet_number: 7777, identity: seen[0].identity.clone() };
|
||||
store.sync_pet_mappings(id, "realm-b", 1, &[moved], &PetProtection::default()).unwrap();
|
||||
assert_eq!(store.active_pet_lookup(id, "realm-b").unwrap()[&7777].0, pet.id);
|
||||
// duplicate observations are refused
|
||||
let dup = PetObservation { portable_pet_id: pet.id, local_pet_number: 1, identity: "x".into() };
|
||||
assert!(store.sync_pet_mappings(id, "realm-b", 1, &[dup.clone(), dup], &PetProtection::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_update_is_journaled_like_an_import_and_changes_the_mappings_only_when_finished() {
|
||||
let (mut store, id, c0) = joined();
|
||||
// canonical moves on: one item removed, one new item
|
||||
let gone = filterable(&c0, 1)[0];
|
||||
let mut c1 = c0.clone();
|
||||
c1.items.retain(|i| i.id != gone);
|
||||
let fresh_item = realm_item(3);
|
||||
let fresh_id = fresh_item.id;
|
||||
c1.items.push(fresh_item.clone());
|
||||
store.commit_snapshot(id, 1, c1.clone(), "realm-a", None).unwrap();
|
||||
|
||||
let plan = UpdatePlan {
|
||||
added_items: vec![PlannedItem { id: fresh_id, entry: fresh_item.entry.clone(), identity: crate::portable::identity::item_identity(&fresh_item.entry, 0) }],
|
||||
retired_items: vec![gone],
|
||||
..UpdatePlan::default()
|
||||
};
|
||||
// updating needs the character to be on the realm already
|
||||
assert!(store.begin_update(id, "realm-z", 2, plan.clone()).is_err());
|
||||
assert!(matches!(store.begin_update(id, "realm-b", 1, plan.clone()), Err(PortableError::StaleRevision { .. })));
|
||||
let ticket = store.begin_update(id, "realm-b", 2, plan.clone()).unwrap();
|
||||
let entry = store.import_entry(ticket.import_id).unwrap();
|
||||
assert_eq!((entry.kind, entry.state, entry.items.len(), entry.retired_items.clone()), (JournalKind::Update, ImportState::Prepared, 1, vec![gone]));
|
||||
assert!(matches!(store.begin_update(id, "realm-b", 2, plan.clone()), Err(PortableError::ImportInProgress { .. })));
|
||||
// nothing changed locally before the realm committed
|
||||
assert_eq!(store.server_mappings(id).unwrap()[0].last_revision, 1);
|
||||
assert!(store.item_mappings(id, "realm-b").unwrap().iter().any(|m| m.portable_item_id == gone));
|
||||
assert!(store.synced_model(id, "realm-b").unwrap().unwrap() == c0);
|
||||
|
||||
store.finish_import(ticket.import_id, ImportAllocation { local_guid: 1013, item_base: 88_000, pet_base: 0 }).unwrap();
|
||||
let mappings = store.item_mappings(id, "realm-b").unwrap();
|
||||
assert!(mappings.iter().all(|m| m.portable_item_id != gone), "the removed item's mapping is retired");
|
||||
let added = mappings.iter().find(|m| m.portable_item_id == fresh_id).unwrap();
|
||||
assert_eq!((added.local_item_guid, added.presence), (88_000, Presence::Present));
|
||||
assert_eq!(store.server_mappings(id).unwrap()[0].last_revision, 2);
|
||||
assert_eq!(store.synced_model(id, "realm-b").unwrap().unwrap(), store.load_current(id).unwrap(), "the realm is synced with what it was updated to");
|
||||
// the local character is the same one
|
||||
assert_eq!(store.server_mappings(id).unwrap()[0].local_guid, 1013);
|
||||
// a wrong guid is refused and rolls back
|
||||
let second = store.begin_update(id, "realm-b", 2, UpdatePlan::default()).unwrap();
|
||||
let r = store.finish_import(second.import_id, ImportAllocation { local_guid: 4040, item_base: 0, pet_base: 0 });
|
||||
assert!(r.is_err() && store.import_entry(second.import_id).unwrap().state == ImportState::Prepared);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Recreate one disposable MySQL realm of the portable-characters live tests from the pristine schema fixture.
|
||||
|
||||
.DESCRIPTION
|
||||
The live tests (crates/coa-core/src/portable/realm/live*.rs) run against two disposable MySQL servers, realm A (port 13998)
|
||||
and realm B (port 13997). Most tests leave them as they found them; the ones that update a fixture character in place
|
||||
(COA_PORTABLE_LIVE_MUTATE_A=1) do not. This script throws the realm's data directory away and builds it again.
|
||||
|
||||
It stops ONLY the mysqld whose process id is in <Root>\<Name>\pid.txt and whose command line names that data directory.
|
||||
It never stops anything by image name.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh tools/reset_live_realm.ps1 -Root $env:TEMP\realms -Name A -Port 13998 -Fixture C:\games\coa-schema-fixture-20261005 -Sql crates\coa-core\src\portable\realm\testdata\realm-fixture.sql
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string] $Root,
|
||||
[Parameter(Mandatory)] [ValidateSet('A', 'B')] [string] $Name,
|
||||
[Parameter(Mandatory)] [int] $Port,
|
||||
[Parameter(Mandatory)] [string] $Fixture,
|
||||
[Parameter(Mandatory)] [string] $Sql,
|
||||
[string] $RootPassword = 'portable-test'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$dir = Join-Path $Root $Name
|
||||
$data = Join-Path $dir 'data'
|
||||
$bin = Join-Path $Fixture 'mysql'
|
||||
$pidFile = Join-Path $dir 'pid.txt'
|
||||
|
||||
if (Test-Path $pidFile) {
|
||||
$old = [int](Get-Content $pidFile -Raw).Trim()
|
||||
$proc = Get-CimInstance Win32_Process -Filter "ProcessId = $old" -ErrorAction SilentlyContinue
|
||||
if ($proc -and $proc.Name -eq 'mysqld.exe' -and $proc.CommandLine -like "*$data*") {
|
||||
Stop-Process -Id $old -Force
|
||||
Wait-Process -Id $old -Timeout 30 -ErrorAction SilentlyContinue
|
||||
} elseif ($proc) {
|
||||
throw "process $old is not the disposable mysqld of realm $Name; refusing to stop it"
|
||||
}
|
||||
}
|
||||
for ($i = 0; $i -lt 20 -and (Test-Path $data); $i++) {
|
||||
try { Remove-Item $data -Recurse -Force -ErrorAction Stop } catch { Start-Sleep 1 }
|
||||
}
|
||||
if (Test-Path $data) { throw "could not remove $data" }
|
||||
robocopy (Join-Path $Fixture 'mysql\data') $data /E /NFL /NDL /NJH /NJS /NP | Out-Null
|
||||
|
||||
$args = @('--no-defaults', '--no-monitor', "--basedir=$bin", "--datadir=$data", "--port=$Port", '--bind-address=127.0.0.1', '--mysqlx=OFF', '--skip-log-bin', '--innodb-buffer-pool-size=256M', "--init-file=$(Join-Path $Root 'init.sql')", '--console')
|
||||
$p = Start-Process -FilePath (Join-Path $bin 'bin\mysqld.exe') -ArgumentList $args -PassThru -WindowStyle Hidden -RedirectStandardOutput (Join-Path $dir 'out.log') -RedirectStandardError (Join-Path $dir 'err.log')
|
||||
$p.Id | Out-File $pidFile
|
||||
for ($i = 0; $i -lt 60; $i++) {
|
||||
Start-Sleep 1
|
||||
if ((Get-Content (Join-Path $dir 'err.log') -Tail 5 -ErrorAction SilentlyContinue) -match 'ready for connections') { break }
|
||||
}
|
||||
$env:MYSQL_PWD = $RootPassword
|
||||
Get-Content $Sql -Raw | & (Join-Path $bin 'bin\mysql.exe') --protocol=tcp --host=127.0.0.1 "--port=$Port" --user=root --default-character-set=utf8mb4 --max-allowed-packet=64M
|
||||
"realm $Name reset: pid $($p.Id), port $Port"
|
||||
Reference in new issue
Block a user