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;
}
}