Class CharacterInventorySystem
- Namespace
- FishMMO.Server.Implementation.World.SceneServer
- Assembly
- FishMMO.Server.dll
Manages player character inventory, equipment, and bank operations with validation and database persistence.
[CreateAssetMenu(fileName = "CharacterInventorySystem", menuName = "FishMMO/Server/SceneServer/Character Inventory System", order = 1)]
[RequiresDataContainer(typeof(CharacterInventorySystemRuntimeData))]
[RequiresDataContainer(typeof(CharacterInventorySystemMainThreadQueueData))]
[RequiresDataContainer(typeof(AsyncWorkerData))]
public class CharacterInventorySystem : ServerBehaviour, IServerBehaviour<INetworkManagerWrapper, ServerManager, NetworkConnection, IServerBehaviour>, IServerComponent<INetworkManagerWrapper, ServerManager, NetworkConnection, IServerBehaviour>, ICharacterInventorySystem, IServerBehaviour, IServerComponent
- Inheritance
-
ObjectScriptableObjectCharacterInventorySystem
- Implements
- Inherited Members
-
ScriptableObject.SetDirty()ScriptableObject.CreateInstance<T>()Object.GetEntityId()Object.GetInstanceID()Object.GetHashCode()Object.InstantiateAsync<T>(T)Object.InstantiateAsync<T>(T, Transform)Object.InstantiateAsync<T>(T, Vector3, Quaternion)Object.InstantiateAsync<T>(T, Transform, Vector3, Quaternion)Object.Instantiate(Object, Vector3, Quaternion)Object.Instantiate(Object, Vector3, Quaternion, Transform)Object.Instantiate(Object)Object.Instantiate(Object, Scene)Object.Instantiate<T>(T, InstantiateParameters)Object.Instantiate<T>(T, Vector3, Quaternion, InstantiateParameters)Object.Instantiate(Object, Transform)Object.Instantiate<T>(T)Object.Instantiate<T>(T, Vector3, Quaternion)Object.Instantiate<T>(T, Vector3, Quaternion, Transform)Object.Instantiate<T>(T, Transform)Object.Destroy(Object)Object.DestroyImmediate(Object)Object.DontDestroyOnLoad(Object)Object.DestroyObject(Object)Object.FindObjectsOfType<T>()Object.FindObjectsByType<T>(FindObjectsSortMode)Object.FindObjectsByType<T>(FindObjectsInactive, FindObjectsSortMode)Object.FindObjectOfType<T>()Object.FindFirstObjectByType<T>()Object.FindAnyObjectByType<T>()Object.FindFirstObjectByType<T>(FindObjectsInactive)Object.FindAnyObjectByType<T>(FindObjectsInactive)Object.FindObjectsByType<T>()Object.FindObjectsByType<T>(FindObjectsInactive)Object.ToString()Object.nameObject.hideFlags
Methods
CaptureDespawnFlush(IPlayerCharacter, CharacterSessionInfo?)
Captures a full snapshot of a departing character's containers, to be awaited by the character system before it releases the session.
public Func<Task> CaptureDespawnFlush(IPlayerCharacter character, CharacterSessionInfo? lease)
Parameters
characterIPlayerCharacterThe departing character.
leaseCharacterSessionInfo?The session this server still holds for it, already taken out of the live token map.
Returns
Remarks
Main thread only, while the character is still resident. The returned work writes the
snapshot in one transaction under lease; the caller awaits it before
the release so the next owner reads it. Returns null when there is nothing to capture.
InitializeOnce()
Initializes the character inventory system, registering broadcast handlers for inventory, equipment, and bank actions.
public override ServerComponentInitializationStatus InitializeOnce()
Returns
NotifyInventorySlots(IPlayerCharacter, IReadOnlyList<Item>, IReadOnlyList<int>)
Tells the owning client which inventory slots an operation outside this system
changed: emptied slots are cleared, then set
items are written to their slots.
public void NotifyInventorySlots(IPlayerCharacter character, IReadOnlyList<Item> set, IReadOnlyList<int> emptied)
Parameters
characterIPlayerCharactersetIReadOnlyList<Item>emptiedIReadOnlyList<int>
Remarks
Removes go first, and only for slots that are still empty, because an exchange can vacate a slot and fill it again in the same step.
OnDeinitialize()
Cleans up the character inventory system, unregistering broadcast handlers for inventory, equipment, and bank actions.
public override void OnDeinitialize()
OnUpdate(float)
Sweeps stale ingress guard entries and drives the periodic item snapshot.
protected override void OnUpdate(float deltaTime)
Parameters
deltaTimefloat
PersistInventoryChanges(IPlayerCharacter, IReadOnlyList<Item>, IReadOnlyList<RemovedItemRecord>)
Persists inventory rows that changed or ceased to exist outside this system's own handlers — a stack reduced by a sale or a mail attachment, an item sold or attached whole — through the same journalled batch every other item write uses.
public void PersistInventoryChanges(IPlayerCharacter character, IReadOnlyList<Item> changed, IReadOnlyList<RemovedItemRecord> removed)
Parameters
characterIPlayerCharacterThe owning character.
changedIReadOnlyList<Item>Items whose rows changed, or null.
removedIReadOnlyList<RemovedItemRecord>Items that were removed, with the versions that authorise the deletes, or null.
Remarks
The journal is what orders these against the periodic snapshot. A delete issued around it could commit after a snapshot captured while the item still existed, and the snapshot would then put the row back. Callers have already told the client themselves.
SwapContainerItems(IItemContainer, IItemContainer, int, int, out List<Item>, out List<long>, out List<Item>)
Swaps items between two containers and collects affected items and deleted slots.
public bool SwapContainerItems(IItemContainer from, IItemContainer to, int fromIndex, int toIndex, out List<Item> affectedFromItems, out List<long> deletedFromSlots, out List<Item> affectedToItems)
Parameters
fromIItemContainerSource item container.
toIItemContainerTarget item container.
fromIndexintSource slot index.
toIndexintTarget slot index.
affectedFromItemsList<Item>Out: items placed into the source container.
deletedFromSlotsList<long>Out: slot indices vacated in the source container.
affectedToItemsList<Item>Out: items placed into the destination container.
Returns
- bool
True if swap succeeded, false otherwise.
SwapContainerItems(IItemContainer, int, int, out List<Item>)
Swaps two items within the same container and collects the affected items.
public bool SwapContainerItems(IItemContainer container, int fromIndex, int toIndex, out List<Item> affectedItems)
Parameters
containerIItemContainerItem container to swap items in.
fromIndexintSource slot index.
toIndexintTarget slot index.
affectedItemsList<Item>Out: list of items whose slots changed.
Returns
- bool
True if swap succeeded, false otherwise.
SwapContainerItems(IItemContainer, int, int, out List<Item>, out int)
Swaps two items within the same container, reporting both the affected items and any slot the swap left empty.
public bool SwapContainerItems(IItemContainer container, int fromIndex, int toIndex, out List<Item> affectedItems, out int vacatedSlot)
Parameters
containerIItemContainerItem container to swap items in.
fromIndexintSource slot index.
toIndexintTarget slot index.
affectedItemsList<Item>Out: list of items whose slots changed.
vacatedSlotintOut: slot the swap emptied, or -1 when both slots stay filled.
Returns
- bool
True if swap succeeded, false otherwise.
Remarks
The vacated slot is the whole reason this overload exists. Item rows are keyed
(character_id, slot), so a move onto an EMPTY slot writes the item at its new
index and leaves the row at the old index untouched — the same item persisted twice.
Only the write side was emitted here before, because affectedItems
collects non-null items and a move-to-empty produces exactly one of them, which reads
like a complete description of the change and is not.
The 60-second snapshot prunes the stale row, so this never survived a clean shutdown — but "duplicated until the next snapshot, permanently if the scene server dies first" is not a guarantee worth relying on when the delete costs one list entry.
TryGrantItem(IPlayerCharacter, Item, InventoryType)
Grants an item to one of a character's containers: places it, tells the owning client, persists it, and hands it the identity the database assigns.
public bool TryGrantItem(IPlayerCharacter character, Item item, InventoryType container)
Parameters
characterIPlayerCharacterThe character receiving the item.
itemItemThe item. May be merged into stacks the character already holds.
containerInventoryTypeWhich container receives it.
Returns
- bool
True when the whole item was placed.
Remarks
THE grant path. Every server-side source of a new item — pickups, corpse and chest loot, merchant purchases, mail, quest and achievement rewards, the ECA give action — comes through here, because it is the only path that completes an item: a fresh item has no database identity, and until its first write returns one it cannot be equipped (the attribute ledger declines an id of zero), used, moved, or safely written a second time. The slot it lands in stays locked until that identity arrives; see TryPersistGrantedItems(IPlayerCharacter, List<Item>, InventoryType, string).
All-or-nothing on the container: the whole stack fits or nothing is placed, so a caller that reserved the item elsewhere (took it off a corpse, charged for it) can put it back on a false return.
TryPersistGrantedItems(IPlayerCharacter, List<Item>, InventoryType, string)
Persists a grant of items — freshly created items and the existing stacks they merged into — through the atomic item-batch machinery, so an item the database has never seen (ID 0) gets the identity the write returns assigned onto the live Item and the owning client is told the final instance id and seed.
public bool TryPersistGrantedItems(IPlayerCharacter character, List<Item> modifiedItems, InventoryType container, string operation)
Parameters
characterIPlayerCharacterThe character the items were granted to.
modifiedItemsList<Item>The items the grant touched, as returned by the container add.
containerInventoryTypeThe container the items are in.
operationstringShort operation name used in persistence log lines.
Returns
- bool
True when the batch was enqueued normally; false when the bounded queue was full and the write ran on the fallback path. Never a rollback signal — memory is already authoritative.
Remarks
An item with no identity has its slot LOCKED from here until the identity lands, so nothing can move, merge, consume or equip it in between. That window is one database round trip. Without the lock a second write captured in that window inserted a second row for the same item, which the load path then handed back as two items.
TryRunExchange(IPlayerCharacter, IPlayerCharacter, Func<ItemExchangeLeg[]>, Action<bool>, string)
Runs a two-character exchange — a completed player trade — as ONE database transaction whose commit is the point of truth, so a crash at any instant leaves the database holding either the whole trade or none of it, and memory never diverges from that in a direction that duplicates anything.
public bool TryRunExchange(IPlayerCharacter first, IPlayerCharacter second, Func<ItemExchangeLeg[]> applyOnMainThread, Action<bool> onFinished, string operation)
Parameters
firstIPlayerCharacterOne party.
secondIPlayerCharacterThe other party.
applyOnMainThreadFunc<ItemExchangeLeg[]>Called on the main thread while both row locks are held. Returns the two legs (
first's thensecond's) after mutating memory, or null to abort before anything was mutated.onFinishedAction<bool>Called on the main thread once, with true when the transaction committed and false when it was aborted or rolled back. Only called with false after a null apply, or with the memory mutation still in place and every touched slot already unlocked, so the caller can undo it.
operationstringShort operation name used in persistence log lines.
Returns
- bool
True when the exchange was accepted for execution. False means nothing will run and nothing was changed.
Remarks
THE PROTOCOL, in order, because the order is the guarantee:
- Worker: open the transaction and take BOTH characters' session row locks, in ascending id order. From here no other write for either character can commit until this transaction ends: every write takes the same lock.
-
Main thread, while the locks are held:
applyOnMainThreadre-validates and mutates memory (or returns null to abort with nothing changed), and the resulting rows are captured with their journal sequences and versions stamped NOW. Every write captured before this instant therefore carries a lower sequence and lower versions than the trade; every write captured after carries the trade's content. The slots in TouchedSlots are locked until the outcome is known. - Worker: write both characters' rows and the ledger, commit.
-
Main thread: unlock, then
onFinished(true). On any refusal: roll back, unlock,onFinished(false) — the caller undoes its memory mutation exactly, which the locks make possible — then every write captured from memory while the exchange was applied is VOIDED in the journal, because its content described a trade that never happened, and both characters are reconciled from the restored memory.
Crash analysis: before the commit, memory is lost and the database rolls back — the trade never happened for either side. After the commit, the database holds the whole trade for both. A write captured before the apply that lands after the commit is older by sequence and version and is refused. There is no interleaving in which one character's half is durable and the other's is not.