namespace Aeshnidae.Mule; /// What one attempted movement did. public sealed record MoveResult(bool Ok, string Message, string Result); /// /// Moving an item into and out of a vault. This is the dangerous file; everything else is /// plumbing around it. /// /// THE ONE RULE /// ------------ /// Items stay real WorldObjects. A deposit changes which container an item is in and /// nothing else. Nothing here reads properties off an item, writes them into a row and /// recreates the item later, and nothing here may: an item's spells, enchantments, tinks, /// imbues, workmanship, item XP, stack size and our 9042/9043 stamps are its biota, and the /// biota goes along because it IS the item. /// /// WHERE OWNERSHIP IS RECORDED - the fact the whole design rests on /// ---------------------------------------------------------------- /// In ACE, which container holds an item is written in exactly ONE place: the ITEM's own /// biota, as PropertyInstanceId.Container (`biota_properties_i_i_d`, type 2). A player's /// biota does not list what it carries; a container's biota does not list its contents. /// Loading works the other way round - "give me everything whose container_Id is me" /// (ShardDatabase.GetInventoryInParallel). /// /// So a movement is ONE ROW CHANGING VALUE. There is no two-phase commit to get wrong, /// no window in which the database says the item is in two places, and no window in which /// it says the item is in neither. That is why this is safe without a transaction spanning /// two saves - and it is why the code below is careful to write that row exactly once per /// movement, and never to write it while the item is between containers. /// /// THE DEPOSIT SEQUENCE, AND WHAT A CRASH AT EACH STEP LEAVES BEHIND /// ----------------------------------------------------------------- /// 0. Every refusal is checked first, before anything moves. A refusal at step 0 has /// touched nothing at all. /// /// 1. player.TryRemoveFromInventory(guid, out item) [MEMORY ONLY - forceSave false] /// The item leaves the player's Inventory dictionary; item.ContainerId and OwnerId go /// null. NOTHING IS WRITTEN. ACE's own TryRemoveFromInventoryWithNetworking is /// deliberately NOT used here: it calls DeepSave(item), which would write /// container_Id = NULL to disk - an item in no container, no landblock and nobody's /// inventory, which is the one state nothing in the game can find again. A crash /// here leaves the database untouched: the item is in the player's pack on restart. /// /// 2. link.TryAddToInventory(item, ..., limitToMainPackOnly: true) [MEMORY ONLY] /// item.ContainerId and OwnerId become the vault link's guid. Still nothing written. /// A crash here leaves the database untouched: the item is in the player's pack. /// If this fails, step 2b puts the item straight back into the player (also memory /// only) and the movement is refused with nothing written. /// /// Steps 1 and 2 are one uninterrupted block on the world thread. That matters: a /// player's own periodic save also runs on the world thread (Player_Tick), so it /// cannot land between them and write the NULL. /// /// 3. item.SaveBiotaToDatabase(false) + Shard.SaveBiota(..., callback) /// THE COMMIT. One row: container_Id player -> vault link. Before it the item is the /// player's, after it the vault's, and there is no instant in between. A crash before /// the write drains leaves the item in the player's pack - the player was not yet /// told it worked. A crash after it lands leaves the item in the vault. /// /// 4. The ledger row, written synchronously at step 3's shoulder (result `moved`). /// A crash between 3 and 4 leaves a correctly placed item with no audit line - the /// only durable inconsistency the sequence can produce, and one that cannot lose or /// duplicate anything. If the save comes back FAILED, a second row says so and the /// item is marked dirty for the next sweep to retry. /// /// 5. The player's client is told the item is gone from the pack, and the player's own /// biota is forced to disk. Neither is load-bearing - the player's biota does not /// record what it holds - and both are done anyway so that nothing about the player /// is stale at the moment the item changes hands. /// /// 6. Only now is the player told it worked, from the save's callback. /// /// THE WITHDRAW SEQUENCE is the same shape with the ends swapped: remove from the link in /// memory, add to the player in memory, put it back in the link if that fails, then one /// confirmed write of the item's biota, then the ledger, then the client messages, then /// the success line. /// /// WHAT IS NOT GUARANTEED, PLAINLY /// ------------------------------- /// There is no single database transaction spanning "the item leaves the player" and "the /// item arrives in the vault", because in ACE those are not two writes - they are one. /// What is genuinely NOT transactional is the pair (item's biota row, ledger row): the /// ledger can be missing a line for a movement that happened, or - if the process dies /// between step 4 and step 3's drain - carry a `moved` line for a movement that did not. /// Neither can lose or duplicate an item. The ledger is the audit, the biota row is the /// truth, and `mule-admin verify` compares them. /// public static class Movement { public const string In = "deposit"; public const string Out = "withdraw"; /// /// DEBUG ONLY, and zero in all normal operation: milliseconds to sleep inside a /// movement, between step 1 (the item leaves its container) and step 2 (it arrives in /// the other), and between step 2 and step 3 (the commit). /// /// They exist because the crash test the vault most needs is "kill the process between /// the two halves of a movement, then account for every item", and those halves are /// microseconds apart. Widening the window on purpose is the only way to aim a SIGKILL /// at it. They are set only by `mule-test pause`, which refuses unless /// Settings.AllowTestHarness is on - and that is false on live. /// /// Leaving them in the shipped path is deliberate: a test that runs against different /// code from the one players use proves nothing about the one players use. /// public static int DebugPauseAfterRemoveMs; /// public static int DebugPauseAfterAddMs; // ---- REFUSALS ------------------------------------------------------------ /// /// Everything that must stop a deposit, checked before a single thing moves. /// Returns null when the item may move, or the line the player is told. /// /// These are our own checks, not ACE's: the vault is not in a landblock, so ACE's own /// HandleActionPutItemInContainer_Verify cannot even find it as a target. What is /// below is that method's list, kept in the same order, minus the parts that only make /// sense for a container on the ground, plus the vault's own rules. /// public static string? RefuseDeposit(Player player, WorldObject item, Container source) { if (item is null) return "Select the item you want to store first."; // ACE's own "this cannot be moved at all" test. if (!item.Guid.IsDynamic() || item is Creature || item.Stuck) return $"{item.Name} cannot be moved."; if (player.IsBusy || player.Teleporting) return "You are too busy."; // In a trade window. Taking it out from under a trade is how an item goes to two // places at once. if (player.IsTrading && item.IsBeingTradedOrContainsItemBeingTraded(player.ItemsInTradeWindow)) return $"{item.Name} is in the trade window."; // Worn or wielded. ACE's dequip is a separate path with its own networking and // its own set of failure modes; the vault does not drive it. if (player.EquippedObjects.ContainsKey(item.Guid) || (item.WielderId ?? 0) != 0) return $"Take off {item.Name} first, then store it."; // Not actually in the player's own packs: on a corpse, on the ground, in a // vendor's stock, in another container they happen to have open. The caller // resolves `source` with SearchLocations.MyInventory only - ACE's own search walks // the player and their side packs and nothing else - so a null here means it was // not found anywhere the vault is willing to take from. if (source is null) return $"{item.Name} is not in your pack."; // A pack with things in it. Deposit the contents, not the pack - otherwise one // vault slot swallows a hundred items and the limit stops meaning anything. if (item is Container holding && holding.Inventory.Count > 0) return $"{item.Name} has things in it. Store what is inside it, not the pack."; // Packs and foci themselves. See Settings.AllowPacksAndFoci for why not yet. if (item.UseBackpackSlot && !Mod.Settings.AllowPacksAndFoci) return $"{item.Name} is a pack. The vault does not take packs or foci yet."; // Attuned is ALLOWED: the vault is the same account, so nothing crosses an // ownership line. The moment shared access or a market exists, this must flip - // AllowAttuned carries the whole explanation. if (!Mod.Settings.AllowAttuned && item.IsAttunedOrContainsAttuned) return $"{item.Name} is attuned to you and cannot be stored."; if (Mod.Settings.RefuseWcids.Contains(item.WeenieClassId)) return $"{item.Name} cannot be stored."; return null; } public static string? RefuseWithdraw(Player player, WorldObject item) { if (player.IsBusy || player.Teleporting) return "You are too busy."; if (player.IsTrading) return "Finish your trade first."; if (!player.CanAddToInventory(item)) return $"You have no room for {item.Name}."; return null; } // ---- DEPOSIT ------------------------------------------------------------- /// /// Stores one item. World thread. Takes the account lock for the whole movement. /// public static MoveResult Deposit(Player player, WorldObject item) { var accountId = player.Account?.AccountId ?? 0; if (accountId == 0) return new MoveResult(false, "Your account could not be read. Nothing has been changed.", "refused:no-account"); var handle = Vault.Get(accountId, out var error); if (handle is null) return new MoveResult(false, error, "refused:not-loaded"); using (Vault.Hold(accountId)) { if (!Vault.IsCurrent(handle)) return new MoveResult(false, "Your vault was closed between reading it and writing to it. Nothing has been changed - try again.", "refused:stale"); var source = item is null ? null : player.FindObject(item.Guid, Player.SearchLocations.MyInventory, out var foundIn, out _, out _) is null ? null : foundIn; var refusal = RefuseDeposit(player, item!, source!); if (refusal is not null) { Ledger(player, accountId, item, In, "refused", refusal, 0); return new MoveResult(false, refusal, "refused"); } // THE LIMIT. Counted off the live containers, under the lock, so two deposits // at once cannot both see the last free slot. if (handle.Count >= Mod.Settings.MaxItems) { var line = $"Your vault is full: {handle.Count:N0} of {Mod.Settings.MaxItems:N0} items. {item!.Name} is still in your pack."; Ledger(player, accountId, item, In, "refused:full", $"{handle.Count} of {Mod.Settings.MaxItems}", 0); return new MoveResult(false, line, "refused:full"); } var link = Vault.LinkWithRoom(handle, out var growError); if (link is null) { var line = growError == "full" ? $"Your vault is full: {handle.Count:N0} of {Mod.Settings.MaxItems:N0} items. {item!.Name} is still in your pack." : $"Your vault could not be opened for writing. {item!.Name} is still in your pack."; Ledger(player, accountId, item, In, growError == "full" ? "refused:full" : "refused:no-link", growError, 0); return new MoveResult(false, line, "refused"); } var moved = MoveIntoVault(player, item!, link, player.Name, out var why); if (!moved) { Ledger(player, accountId, item, In, why, "", link.Guid.Full); return new MoveResult(false, $"{item!.Name} could not be stored and is still in your pack.", why); } Ledger(player, accountId, item, In, "moved", "", link.Guid.Full); // Step 5: the client, then the player's own row. player.Session?.Network.EnqueueSend( new GameMessageInventoryRemoveObject(item!), new GameMessagePrivateUpdatePropertyInt(player, PropertyInt.EncumbranceVal, player.EncumbranceVal ?? 0)); if (item!.WeenieType is WeenieType.Coin or WeenieType.Container) RefreshCoinValue(player); player.SaveBiotaToDatabase(); // Step 6: tell the player only once the item's own row is on disk. Confirm(player, new[] { item }, accountId, In, handle.Count, Mod.Settings.MaxItems, $"{Describe(item)} is in your vault."); return new MoveResult(true, "", "moved"); } } /// /// Steps 1-3 of the deposit sequence for one item: out of the player in memory, into /// the link in memory, then the single confirmed write. Call under the account lock, /// on the world thread. /// /// The put-back at 2b is the only path that can leave an item nowhere, and it can only /// be reached if the player's own pack refuses to take back an item that was in it a /// microsecond ago. If that ever happens the item is still a live WorldObject held by /// `item` and it is logged by guid, so it can be recovered - but nothing has been /// written, so the database still says the player has it, and a restart puts it back. /// /// /// The container the item is leaving. A Player IS a Container, so the live path passes /// the player and the console test harness passes a stand-in container: both go through /// the identical three steps, which is the point - the harness is not testing a /// different code path from the one players use. /// public static bool MoveIntoVault(Container source, WorldObject item, Container link, string actor, out string why) { why = ""; // 1. Out of the source. MEMORY ONLY - forceSave stays false, and ACE's // ...WithNetworking variant is avoided because its DeepSave would write // container_Id = NULL. See the file comment. if (!source.TryRemoveFromInventory(item.Guid, out var taken)) { why = "refused:not-removed"; return false; } if (DebugPauseAfterRemoveMs > 0) Thread.Sleep(DebugPauseAfterRemoveMs); // 2. Into the vault link. MEMORY ONLY. limitToMainPackOnly so nothing can cascade // into a sub-container (there are none; the weenie has ContainersCapacity 0). if (!link.TryAddToInventory(taken, out _, link.Inventory.Count, true, false)) { // 2b. Straight back where it came from. Still nothing written. if (!source.TryAddToInventory(taken)) { why = "stranded"; ModManager.Log($"[{Mod.Name}] STRANDED: {taken.Name} (0x{taken.Guid.Full:X8}, wcid {taken.WeenieClassId}) left {actor}'s pack, the vault refused it and the pack refused it back. NOTHING WAS WRITTEN - the shard still says {actor} has it, so a relog or restart returns it. Object is still live in memory.", ModManager.LogLevel.Error); return false; } why = "refused:vault-refused"; return false; } if (DebugPauseAfterAddMs > 0) Thread.Sleep(DebugPauseAfterAddMs); // 3. THE COMMIT. One row. SaveBiotaToDatabase(false) does ACE's own bookkeeping // (position cache flush, ChangesDetected clear) without enqueuing, then the // write goes in with a callback so the player is told only when it has landed. taken.SaveBiotaToDatabase(false); return true; } // ---- WITHDRAW ------------------------------------------------------------ public static MoveResult Withdraw(Player player, WorldObject item) { var accountId = player.Account?.AccountId ?? 0; if (accountId == 0) return new MoveResult(false, "Your account could not be read. Nothing has been changed.", "refused:no-account"); var handle = Vault.Get(accountId, out var error); if (handle is null) return new MoveResult(false, error, "refused:not-loaded"); using (Vault.Hold(accountId)) { if (!Vault.IsCurrent(handle)) return new MoveResult(false, "Your vault was closed between reading it and writing to it. Nothing has been changed - try again.", "refused:stale"); var link = handle.Links.FirstOrDefault(l => l.Inventory.ContainsKey(item.Guid)); if (link is null) { // Someone else on the account took it between the list and the command. Ledger(player, accountId, item, Out, "refused:gone", "", 0); return new MoveResult(false, $"{item.Name} is no longer in your vault.", "refused:gone"); } var refusal = RefuseWithdraw(player, item); if (refusal is not null) { Ledger(player, accountId, item, Out, "refused", refusal, link.Guid.Full); return new MoveResult(false, refusal, "refused"); } // 1. Out of the link, MEMORY ONLY. Storage.OnRemoveItem fires here and saves // the LINK's own row - not the item's - so no container_Id = NULL ever // reaches disk. if (!link.TryRemoveFromInventory(item.Guid, out var taken)) { Ledger(player, accountId, item, Out, "refused:not-removed", "", link.Guid.Full); return new MoveResult(false, $"{item.Name} could not be taken out of your vault.", "refused:not-removed"); } if (DebugPauseAfterRemoveMs > 0) Thread.Sleep(DebugPauseAfterRemoveMs); // 2. Into the player, MEMORY ONLY. if (!player.TryAddToInventory(taken, out var into)) { if (!link.TryAddToInventory(taken, out _, link.Inventory.Count, true, false)) { ModManager.Log($"[{Mod.Name}] STRANDED: {taken.Name} (0x{taken.Guid.Full:X8}) left vault link 0x{link.Guid.Full:X8} for {player.Name} and neither would take it back. NOTHING WAS WRITTEN - the shard still says the vault has it. Object is still live in memory.", ModManager.LogLevel.Error); Ledger(player, accountId, taken, Out, "stranded", "", link.Guid.Full); return new MoveResult(false, $"{taken.Name} could not be moved. Nothing has been changed on disk.", "stranded"); } Ledger(player, accountId, taken, Out, "refused:no-room", "", link.Guid.Full); return new MoveResult(false, $"You have no room for {taken.Name}. It is still in your vault.", "refused:no-room"); } if (DebugPauseAfterAddMs > 0) Thread.Sleep(DebugPauseAfterAddMs); // 3. THE COMMIT. taken.SaveBiotaToDatabase(false); Ledger(player, accountId, taken, Out, "moved", "", link.Guid.Full); // 4. The client. This is TryCreateInInventoryWithNetworking's message set, // sent by hand because that method also does the add and its own // uncallbacked save, and this movement wants exactly one confirmed write. player.Session?.Network.EnqueueSend(new GameMessageCreateObject(taken)); if (taken is Container asContainer) { player.Session?.Network.EnqueueSend( new ACE.Server.Network.GameEvent.Events.GameEventViewContents(player.Session!, asContainer)); foreach (var inner in asContainer.Inventory.Values) player.Session?.Network.EnqueueSend(new GameMessageCreateObject(inner, player.Adminvision, false)); } player.Session?.Network.EnqueueSend( new ACE.Server.Network.GameEvent.Events.GameEventItemServerSaysContainId(player.Session!, taken, into), new GameMessagePrivateUpdatePropertyInt(player, PropertyInt.EncumbranceVal, player.EncumbranceVal ?? 0)); if (taken.WeenieType is WeenieType.Coin or WeenieType.Container) RefreshCoinValue(player); player.SaveBiotaToDatabase(); Confirm(player, new[] { taken }, accountId, Out, handle.Count, Mod.Settings.MaxItems, $"{Describe(taken)} is in your pack."); return new MoveResult(true, "", "moved"); } } // ---- BULK ---------------------------------------------------------------- /// /// Everything in one of the player's packs, one item at a time through the same /// single-item sequence. Each item gets its own commit and its own ledger row; a /// refusal part-way through stops nothing that already moved and moves nothing that /// did not. /// public static MoveResult DepositAll(Player player, Container pack) { var accountId = player.Account?.AccountId ?? 0; if (accountId == 0) return new MoveResult(false, "Your account could not be read.", "refused:no-account"); var handle = Vault.Get(accountId, out var error); if (handle is null) return new MoveResult(false, error, "refused:not-loaded"); var candidates = pack.Inventory.Values.ToList(); if (candidates.Count == 0) return new MoveResult(false, $"{pack.Name} is empty.", "refused:empty"); var moved = new List(); var entries = new List(); var refused = new List(); using (Vault.Hold(accountId)) { if (!Vault.IsCurrent(handle)) return new MoveResult(false, "Your vault was closed between reading it and writing to it. Nothing has been changed - try again.", "refused:stale"); foreach (var item in candidates) { if (moved.Count >= Mod.Settings.MaxBulk) { refused.Add($"only the first {Mod.Settings.MaxBulk} were moved"); break; } var refusal = RefuseDeposit(player, item, pack); if (refusal is not null) { entries.Add(Entry(player, accountId, item, In, "refused", refusal, 0)); refused.Add(refusal); continue; } if (handle.Count >= Mod.Settings.MaxItems) { entries.Add(Entry(player, accountId, item, In, "refused:full", $"{handle.Count} of {Mod.Settings.MaxItems}", 0)); refused.Add($"your vault is full at {Mod.Settings.MaxItems:N0}"); break; } var link = Vault.LinkWithRoom(handle, out var growError); if (link is null) { entries.Add(Entry(player, accountId, item, In, growError == "full" ? "refused:full" : "refused:no-link", growError, 0)); refused.Add(growError == "full" ? $"your vault is full at {Mod.Settings.MaxItems:N0}" : "the vault could not be opened for writing"); break; } if (!MoveIntoVault(player, item, link, player.Name, out var why)) { entries.Add(Entry(player, accountId, item, In, why, "", link.Guid.Full)); refused.Add($"{item.Name} could not be stored"); continue; } entries.Add(Entry(player, accountId, item, In, "moved", "", link.Guid.Full)); moved.Add(item); player.Session?.Network.EnqueueSend(new GameMessageInventoryRemoveObject(item)); } } MuleDb.Write(entries); if (moved.Count > 0) { player.Session?.Network.EnqueueSend( new GameMessagePrivateUpdatePropertyInt(player, PropertyInt.EncumbranceVal, player.EncumbranceVal ?? 0)); RefreshCoinValue(player); player.SaveBiotaToDatabase(); } var tail = refused.Count == 0 ? "" : $" {refused.Count} left behind ({refused.Distinct().First()})."; if (moved.Count == 0) return new MoveResult(false, $"Nothing was stored from {pack.Name}.{tail}", "refused"); Confirm(player, moved, accountId, In, handle.Count, Mod.Settings.MaxItems, $"{moved.Count} item(s) from {pack.Name} are in your vault.{tail}"); return new MoveResult(true, "", "moved"); } // ---- THE COMMIT'S CONFIRMATION ------------------------------------------- /// /// Enqueues the shard write for every item that moved and tells the player once they /// have all come back. The success line is sent from the database's own callback and /// not before, so "it is in your vault" is never said about a row that has not landed. /// /// A failed write leaves the item where memory says it is - in the vault after a /// deposit - and marks it dirty so the next sweep tries again. The player is told /// plainly, because the one thing worse than a failed deposit is a silent one. /// private static void Confirm(Player player, IReadOnlyList items, uint accountId, string direction, int countNow, int max, string success) { var outstanding = items.Count; var failed = 0; foreach (var item in items) { DatabaseManager.Shard.SaveBiota(item.Biota, item.BiotaDatabaseLock, ok => { if (!ok) { Interlocked.Increment(ref failed); // Make the sweep retry it, and leave a second ledger line. item.ChangesDetected = true; MuleDb.Write(Entry(player, accountId, item, direction, "save-failed", "", 0)); ModManager.Log($"[{Mod.Name}] SAVE FAILED writing {item.Name} (0x{item.Guid.Full:X8}) after a {direction} by {player.Name}. In memory it is where it was moved to; on disk it is where it was. Marked for retry.", ModManager.LogLevel.Error); } if (Interlocked.Decrement(ref outstanding) != 0) return; var bad = Volatile.Read(ref failed); if (bad == 0) Say(player, $"{success} ({countNow:N0} of {max:N0})"); else if (bad == items.Count) Say(player, "The vault could not be written to. Tell a developer before storing anything else."); else Say(player, $"{success} ({countNow:N0} of {max:N0}) - but {bad} could not be written and will be retried."); }); } } /// /// ACE's Player.UpdateCoinValue is private, and it is the only thing that recomputes /// the pyreal total the client shows from what is actually in the packs. Reached by /// reflection once rather than reimplemented, so the number can never drift from /// ACE's own idea of it. /// private static readonly System.Reflection.MethodInfo? _updateCoinValue = AccessTools.Method(typeof(Player), "UpdateCoinValue", new[] { typeof(bool) }); private static void RefreshCoinValue(Player player) { try { _updateCoinValue?.Invoke(player, new object[] { true }); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not refresh {player.Name}'s coin total: {ex.Message}", ModManager.LogLevel.Warn); } } private static void Say(Player player, string line) => player.Session?.Network.EnqueueSend(new GameMessageSystemChat(line, ChatMessageType.Broadcast)); // ---- THE LEDGER ---------------------------------------------------------- private static MuleDb.Entry Entry(Player player, uint accountId, WorldObject? item, string direction, string result, string detail, uint containerId) => new(accountId, player?.Guid.Full ?? 0, player?.Name ?? "", item?.Guid.Full ?? 0, item?.WeenieClassId ?? 0, item?.Name ?? "", item?.StackSize ?? 1, direction, result, detail, containerId); private static void Ledger(Player player, uint accountId, WorldObject? item, string direction, string result, string detail, uint containerId) => MuleDb.Write(Entry(player, accountId, item, direction, result, detail, containerId)); /// "3 x Pyreal" or "Nether Longbow". public static string Describe(WorldObject item) { var size = item.StackSize ?? 1; return size > 1 ? $"{size:N0} x {item.Name}" : item.Name; } }