diff --git a/crates/coa-core/src/portable/error.rs b/crates/coa-core/src/portable/error.rs index d1b95d3..17c0a0a 100644 --- a/crates/coa-core/src/portable/error.rs +++ b/crates/coa-core/src/portable/error.rs @@ -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), + #[error("invalid data: {0}")] Invalid(String), #[error("limit exceeded: {0}")] diff --git a/crates/coa-core/src/portable/merge.rs b/crates/coa-core/src/portable/merge.rs new file mode 100644 index 0000000..195fc76 --- /dev/null +++ b/crates/coa-core/src/portable/merge.rs @@ -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, + /// What differs between `target` and `base` and was therefore deliberately left alone. + pub left_alone: Vec, + pub conflicts: Vec, + 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, + /// Present in `target` and `base`, gone from `ours`: the player got rid of them. + pub removed: Vec, + /// Present in all three and different in `ours`. + pub changed: Vec, + /// In `target` only and still absent from `ours`: filtered away by the realm, kept. + pub filtered_kept: Vec, + /// In `target` only and back in `ours`. + pub reappeared: Vec, + /// In `base` only: the realm's own additions (or filtered at the target), never merged. + pub realm_local: Vec, + /// Items moved to a free slot because the place they were in was taken. + pub relocated: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PetOutcome { + pub added: Vec, + pub removed: Vec, + pub changed: Vec, + pub filtered_kept: Vec, + pub reappeared: Vec, + pub realm_local: Vec, +} + +struct Ctx { + mode: Mode, + conflicts: Vec, + changes: Vec, + left_alone: Vec, +} + +fn short(v: &T) -> String { + let s = format!("{v:?}"); + if s.len() > 60 { + format!("{}...", s.chars().take(57).collect::()) + } else { + s + } +} + +impl Ctx { + fn conflict(&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(&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::()).collect::, _>>(); + 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 = (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(&mut self, path: &str, target: &mut BTreeMap, base: &BTreeMap, ours: &BTreeMap) { + 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(&mut self, path: &str, target: &mut BTreeSet, base: &BTreeSet, ours: &BTreeSet) { + let mut t: BTreeMap = target.iter().map(|k| (k.clone(), ())).collect(); + let b: BTreeMap = base.iter().map(|k| (k.clone(), ())).collect(); + let o: BTreeMap = ours.iter().map(|k| (k.clone(), ())).collect(); + self.map(path, &mut t, &b, &o); + *target = t.into_keys().collect(); + } +} + +fn to_map(items: &[T], f: impl Fn(&T) -> (K, V)) -> BTreeMap { + items.iter().map(f).collect() +} + +/// Apply `base -> ours` onto `target`. +pub fn merge3(target: &PortableCharacter, base: &PortableCharacter, ours: &PortableCharacter, mode: Mode) -> Result { + 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 = 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 { + let by_id = |m: &PortableCharacter| -> HashMap { 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 = t.clone(); + let mut outcome = ItemOutcome::default(); + let mut touched: HashSet = 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 = 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, u8)>) -> Option { + (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) -> Result> { + let mut relocated = Vec::new(); + let ids: HashSet = items.iter().map(|i| i.id).collect(); + let top: HashSet = items.iter().filter(|i| i.container.is_none()).map(|i| i.id).collect(); + let mut taken: HashSet<(Option, u8)> = HashSet::new(); + // a stable order: touched first, then by id + let mut order: Vec = (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 { 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; diff --git a/crates/coa-core/src/portable/merge/tests.rs b/crates/coa-core/src/portable/merge/tests.rs new file mode 100644 index 0000000..37c13e7 --- /dev/null +++ b/crates/coa-core/src/portable/merge/tests.rs @@ -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 { + let containers: HashSet = 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) { + 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 = 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); + } +} diff --git a/crates/coa-core/src/portable/migrations/004_reconciliation.sql b/crates/coa-core/src/portable/migrations/004_reconciliation.sql new file mode 100644 index 0000000..cf1b081 --- /dev/null +++ b/crates/coa-core/src/portable/migrations/004_reconciliation.sql @@ -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; diff --git a/crates/coa-core/src/portable/mod.rs b/crates/coa-core/src/portable/mod.rs index a8cedbf..81e8880 100644 --- a/crates/coa-core/src/portable/mod.rs +++ b/crates/coa-core/src/portable/mod.rs @@ -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; diff --git a/crates/coa-core/src/portable/realm/export.rs b/crates/coa-core/src/portable/realm/export.rs index a18debd..3a71364 100644 --- a/crates/coa-core/src/portable/realm/export.rs +++ b/crates/coa-core/src/portable/realm/export.rs @@ -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, /// Active item mappings of that character on this realm: local item guid -> (portable item id, identity). pub prior_items: &'a HashMap, + /// Active pet mappings of that character on this realm: local pet number -> (portable pet id, identity). + pub prior_pets: &'a HashMap, } #[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, + /// One entry per exported pet, ready for the pet mappings. + pub pet_observations: Vec, /// Things that were left out or looked wrong; shown to the user, never silently dropped. pub warnings: Vec, } @@ -182,7 +186,7 @@ pub fn build(raw: &RawExport, req: &ExportRequest<'_>) -> Result { .map(|r| Ok(ActionButton { spec: r.u8("spec")?, button: r.u8("button")?, action: r.u32("action")?, kind: r.u8("type")? })) .collect::>()?; - 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 { .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> { @@ -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> { +fn pets(raw: &RawExport, ns: &str, prior: &HashMap) -> Result<(Vec, Vec)> { let mut spells: HashMap> = 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> { 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)`. diff --git a/crates/coa-core/src/portable/realm/import.rs b/crates/coa-core/src/portable/realm/import.rs index 577f53c..3107e25 100644 --- a/crates/coa-core/src/portable/realm/import.rs +++ b/crates/coa-core/src/portable/realm/import.rs @@ -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), /// 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 { .collect() } +pub(crate) fn planned_pets(model: &PortableCharacter) -> Vec { + 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 { 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 }); } diff --git a/crates/coa-core/src/portable/realm/live_import.rs b/crates/coa-core/src/portable/realm/live_import.rs index 3b26be6..8e9354b 100644 --- a/crates/coa-core/src/portable/realm/live_import.rs +++ b/crates/coa-core/src/portable/realm/live_import.rs @@ -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} = ||"); 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 { +pub(super) fn realms() -> Option { 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 { }) } -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 { +pub(super) fn on_b(store: &Store, id: CharacterId) -> Vec { 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 { +pub(super) fn counts(db: &Db) -> BTreeMap { let tables = db.tables("acore_characters").unwrap(); let sql = tables.iter().map(|t| format!("SELECT '{t}', COUNT(*) FROM acore_characters.`{t}`")).collect::>().join(" UNION ALL "); db.query(&sql).unwrap().lines().map(|l| { @@ -92,18 +92,18 @@ fn counts(db: &Db) -> BTreeMap { }).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) { +pub(super) fn json_diff(path: &str, a: &serde_json::Value, b: &serde_json::Value, out: &mut Vec) { 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(); diff --git a/crates/coa-core/src/portable/realm/live_update.rs b/crates/coa-core/src/portable/realm/live_update.rs new file mode 100644 index 0000000..cabeeb9 --- /dev/null +++ b/crates/coa-core/src/portable/realm/live_update.rs @@ -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="|13998|" COA_PORTABLE_LIVE_B="|13997|" 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 { + 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 = 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 { + let containers: std::collections::HashSet = 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 { + 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!["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::().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 = 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 = 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"); +} diff --git a/crates/coa-core/src/portable/realm/mod.rs b/crates/coa-core/src/portable/realm/mod.rs index 2ac166a..3c049b7 100644 --- a/crates/coa-core/src/portable/realm/mod.rs +++ b/crates/coa-core/src/portable/realm/mod.rs @@ -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, prior_items: &HashMap) -> Result { + 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, prior_items: &HashMap, prior_pets: &HashMap) -> Result { 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; diff --git a/crates/coa-core/src/portable/realm/plan.rs b/crates/coa-core/src/portable/realm/plan.rs index 4705a15..2a7f8ee 100644 --- a/crates/coa-core/src/portable/realm/plan.rs +++ b/crates/coa-core/src/portable/realm/plan.rs @@ -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 { +pub(super) fn to_u32(what: &str, v: u64) -> Result { 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 { +pub(super) fn content_id(what: &str, id: &ContentId, ns: &str, kind: &str) -> Result { 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 { +pub(super) fn charges_text(item: &PortableItem) -> Option { if item.charges.is_empty() { return None; } diff --git a/crates/coa-core/src/portable/realm/policy.rs b/crates/coa-core/src/portable/realm/policy.rs index f2c4e19..65886ec 100644 --- a/crates/coa-core/src/portable/realm/policy.rs +++ b/crates/coa-core/src/portable/realm/policy.rs @@ -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, diff --git a/crates/coa-core/src/portable/realm/reconcile.rs b/crates/coa-core/src/portable/realm/reconcile.rs new file mode 100644 index 0000000..f0410e5 --- /dev/null +++ b/crates/coa-core/src/portable/realm/reconcile.rs @@ -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, + pets: HashMap, +} + +fn read_realm(db: &Db, store: &Store, id: CharacterId, server_id: &str, local_guid: u32) -> Result { + 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, +} + +/// 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 { + 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::>(), baseline.c0.pets.iter().map(|p| p.id).collect::>()); + 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, + /// What differs between the canonical character and the realm's `B0` and was deliberately not touched. + pub left_alone: Vec, + pub items: ItemOutcome, + pub pets: PetOutcome, + pub warnings: Vec, +} + +/// 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 { + 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, + 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, + pub left_alone: Vec, + pub warnings: Vec, +} + +fn update_problems(db: &Db, local_guid: u32, items: &std::collections::BTreeSet, creatures: &std::collections::BTreeSet, opts: &ImportOptions) -> Result> { + let users = opts.game_server_users.iter().map(|u| Val::text(u.clone()).sql()).collect::>().join(", "); + let count = |name: &str, sql: String| Query { name: name.to_string(), columns: vec!["n"], sql }; + let ids = |set: &std::collections::BTreeSet| set.iter().map(u32::to_string).collect::>().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 { 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> { + if !raw.has(name) { + return Ok(Default::default()); + } + raw.section(name)?.iter().map(|r| r.u32("entry")).collect() + }; + let missing: Vec = items.difference(&known("item_entries")?).copied().collect(); + if !missing.is_empty() { + problems.push(ImportProblem::MissingItems(missing)); + } + let missing: Vec = 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 { + 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 { + 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 { 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::().ok(), p.parse::().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 { + 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 { + let items: HashMap = current.items.iter().enumerate().map(|(i, it)| (it.id, i as u32 + 1)).collect(); + let pets: HashMap = 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) +} diff --git a/crates/coa-core/src/portable/realm/sqlenc.rs b/crates/coa-core/src/portable/realm/sqlenc.rs index a5b9ee7..4bdd4f6 100644 --- a/crates/coa-core/src/portable/realm/sqlenc.rs +++ b/crates/coa-core/src/portable/realm/sqlenc.rs @@ -101,10 +101,90 @@ impl Insert { } } +fn condition(filter: &[(&'static str, Val)]) -> String { + filter.iter().map(|(c, v)| format!("`{c}` = {}", v.sql())).collect::>().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 { + if self.sets.is_empty() { + return None; + } + let sets = self.sets.iter().map(|(c, v)| format!("`{c}` = {}", v.sql())).collect::>().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)>, +} + +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) -> Self { + Self { table, filter, any_of: Some((column, values)) } + } + pub fn sql(&self) -> Option { + 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::>().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', ';', '`', '-', '#', '/', '*', '%', ' ']; diff --git a/crates/coa-core/src/portable/realm/tests.rs b/crates/coa-core/src/portable/realm/tests.rs index 729151a..5541b68 100644 --- a/crates/coa-core/src/portable/realm/tests.rs +++ b/crates/coa-core/src/portable/realm/tests.rs @@ -43,7 +43,7 @@ fn raw(guid: u32) -> RawExport { } fn export_as(guid: u32, ruleset: Ruleset, prior: &HashMap) -> Result { - 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(); diff --git a/crates/coa-core/src/portable/realm/update.rs b/crates/coa-core/src/portable/realm/update.rs new file mode 100644 index 0000000..5f6643f --- /dev/null +++ b/crates/coa-core/src/portable/realm/update.rs @@ -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, + pub pets: &'a HashMap, +} + +/// 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, + pub added_pets: Vec, + pub removed_items: Vec, + pub removed_pets: Vec, + pub counts: UpdateCounts, + pub marker_data: String, +} + +fn diff(current: &BTreeMap, new: &BTreeMap) -> (Vec, 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 = 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, + deletes: Vec, + updates: Vec, + inserts: Vec, +} + +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 { + 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 { 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 { 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 { 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 { 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 { 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 { 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> { 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 = current.items.iter().map(|i| (i.id, i)).collect(); + let new_items: HashMap = 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 = current.items.iter().map(|i| i.id).filter(|id| !new_items.contains_key(id)).collect(); + let added_items: Vec = merged.items.iter().map(|i| i.id).filter(|id| !cur_items.contains_key(id)).collect(); + let added_index: HashMap = added_items.iter().enumerate().map(|(i, id)| (*id, i as u32)).collect(); + let local = |id: &PortableItemId| -> Result { + 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 { item.container.as_ref().map_or(Ok(Val::u(0u8)), local) }; + + let mut moved_inventory: Vec = Vec::new(); + let mut regift: Vec = 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 = 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 { 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 = current.pets.iter().map(|p| (p.id, p)).collect(); + let new_pets: HashMap = 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 = current.pets.iter().map(|p| p.id).filter(|id| !new_pets.contains_key(id)).collect(); + let added_pets: Vec = 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 = 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 { 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::>().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::().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, BTreeSet) { + let items: BTreeSet = 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 = merged.pets.iter().filter(|p| added_pets.contains(&p.id)).filter_map(|p| u32::try_from(p.entry.id()).ok()).collect(); + (items, pets) +} diff --git a/crates/coa-core/src/portable/realm/update_tests.rs b/crates/coa-core/src/portable/realm/update_tests.rs new file mode 100644 index 0000000..5198094 --- /dev/null +++ b/crates/coa-core/src/portable/realm/update_tests.rs @@ -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, HashMap) { + (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) -> 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 = 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()); +} diff --git a/crates/coa-core/src/portable/store.rs b/crates/coa-core/src/portable/store.rs index 2074276..7db52b6 100644 --- a/crates/coa-core/src/portable/store.rs +++ b/crates/coa-core/src/portable/store.rs @@ -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, pub retired_reason: Option, } +/// 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 { + 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, + /// Observed ids that are the realm's own additions. + pub realm_local_items: std::collections::HashSet, +} + #[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> { 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>(7)?, r.get::<_, Option>(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 { + 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 { 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::().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; diff --git a/crates/coa-core/src/portable/store/journal.rs b/crates/coa-core/src/portable/store/journal.rs index 9e0d3f9..873a5b3 100644 --- a/crates/coa-core/src/portable/store/journal.rs +++ b/crates/coa-core/src/portable/store/journal.rs @@ -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 = ` -//! 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 = ` +//! 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 { + 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, - pub pet_ids: Vec, + /// Pets the realm gets: pet `i` is `pet_base + i`. + pub pets: Vec, + /// An update only: items / pets removed from the realm. + pub retired_items: Vec, + pub retired_pets: Vec, pub allocation: Option, pub detail: Option, 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, + pub added_pets: Vec, + pub retired_items: Vec, + pub retired_pets: Vec, +} + 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 { + pub fn begin_import(&mut self, character: CharacterId, server_id: &str, revision: u64, items: &[PlannedItem], pets: &[PlannedPet]) -> Result { + 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 { + 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 { 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::>()?; + 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::>()?; + + 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 = 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 = 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, Vec) = 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> { 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 = stmt.query_map([server_id], |r| r.get(0))?.collect::>()?; @@ -197,15 +299,16 @@ impl Store { } fn read_journal(conn: &Connection, import_id: ImportId) -> Result { - type Row = (String, String, i64, String, String, String, String, Option, Option, Option, Option, String, String); + type Row = (String, String, i64, String, String, String, String, Option, Option, Option, Option, String, String, String, String, String); let row: Option = 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 }; 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, diff --git a/crates/coa-core/src/portable/store/sync.rs b/crates/coa-core/src/portable/store/sync.rs new file mode 100644 index 0000000..77a7bd8 --- /dev/null +++ b/crates/coa-core/src/portable/store/sync.rs @@ -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, + pub retired_reason: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct PetProtection { + pub canonical_pets: HashSet, + pub realm_local_pets: HashSet, +} + +#[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 { + 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 { + 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 = 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::>()?; + 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::().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> { + 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>(7)?, r.get::<_, Option>(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 { + 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> { + 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> { + read_pet_mappings(&self.conn, id, server_id, false) + } + + pub fn pet_mapping_history(&self, id: CharacterId, server_id: &str) -> Result> { + 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> { + let row: Option<(Option>, Option>)> = 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 { + 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 = c0.items.iter().map(|i| i.id).collect(); + let c0_pets: HashSet = 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> { + 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 { + 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 { + 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 = 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 = 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 = 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> { + type Row = (i64, i64, Vec, Vec, Vec, Vec, String); + let row: Option = 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, + })) +} diff --git a/crates/coa-core/src/portable/store/tests.rs b/crates/coa-core/src/portable/store/tests.rs index a4f7ad0..692b8d4 100644 --- a/crates/coa-core/src/portable/store/tests.rs +++ b/crates/coa-core/src/portable/store/tests.rs @@ -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 { .collect() } +fn planned_pets_of(store: &Store, id: CharacterId) -> Vec { + 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::>(); + 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 { + 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 { + let containers: std::collections::HashSet = 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) { + 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 { + 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); +} diff --git a/tools/reset_live_realm.ps1 b/tools/reset_live_realm.ps1 new file mode 100644 index 0000000..2c3653f --- /dev/null +++ b/tools/reset_live_realm.ps1 @@ -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 \\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"