Class CharacterItemService
Service for a character's items across every container.
public sealed class CharacterItemService : BaseService<CharacterItemEntity>, ICharacterItemService, IPersistAction<CharacterItemData, long>, IPersistManyAction<CharacterItemData>, IDeleteByKeyVersionedAction<long>, IFetchCollectionByKeyAction<long, CharacterItemData>
- Inheritance
-
CharacterItemService
- Implements
- Inherited Members
Remarks
IDENTITY CONTRACT — read this before touching any SQL below. A row IS an item. The
conflict target of every upsert is the primary key, and container and slot are
ordinary columns that an update is free to change. Moving an item therefore updates the row
it already had, and its id is stable for the item's whole life.
The three tables this replaces keyed their rows (character_id, slot), which made the
row an occupancy record rather than an item. Three consequences followed, and all three are
gone here: an item that moved slots became a different row with a different id; two items
that passed through one slot shared an id; and because character_inventory,
character_equipment and character_bank each had their own identity sequence,
three unrelated items were routinely handed the same number. That last one is why the
runtime Item had to carry a second, process-local identity to key its attribute
contributions by.
An id of zero means "never written". Both write paths draw the next identity from the table's own sequence for such a row and hand it back, and the caller writes it onto the runtime item. The write-back is not cosmetic: the item's attribute-ledger key is its id, so an item that never learns its id gets a new row — and a new ledger key — on every save.
The (character_id, container, slot) unique index is not a conflict target. It
exists to stop two items claiming one slot, a state the in-memory container cannot represent.
It is checked per row, so a statement that moves several items at once can trip it halfway
through even when the end state is legal. SaveSnapshotAsync(long, IReadOnlyCollection<ItemContainerType>, IEnumerable<CharacterItemData>, CancellationToken) avoids that by
deleting the character's rows before re-inserting them; the incremental paths move one item
at a time. A stale row still sitting on a slot that an incoming item claims surfaces as a
UNIQUE_VIOLATION and is repaired by the next snapshot.
Constructors
CharacterItemService(INpgsqlDbContextFactory)
public CharacterItemService(INpgsqlDbContextFactory dbContextFactory)
Parameters
dbContextFactoryINpgsqlDbContextFactory
Methods
DeleteAsync(long, long, CancellationToken)
Deletes the entity identified by the given key if incomingVersion is newer.
public Task<DatabaseResult> DeleteAsync(long characterId, long incomingVersion, CancellationToken cancellationToken = default)
Parameters
characterIdlongincomingVersionlongThe authoritative, monotonic version for this delete operation.
cancellationTokenCancellationTokenToken to cancel the operation.
Returns
DeleteItemAsync(long, long, long, CancellationToken)
Deletes one item by its identity, if incomingVersion is newer.
public Task<DatabaseResult> DeleteItemAsync(long characterId, long itemId, long incomingVersion, CancellationToken cancellationToken = default)
Parameters
characterIdlongThe character that owns the item. Checked, so one character cannot delete another's row.
itemIdlongThe item's identity.
incomingVersionlongThe authoritative, monotonic version for this delete.
cancellationTokenCancellationTokenToken to cancel the operation.
Returns
Remarks
Addressed by the item rather than by the slot it happens to be sitting in. Under the old
per-slot schema a delete had to name a (character, slot) pair and quote a version
that belonged to whatever item occupied it, which is how a vacated slot ended up holding
a tombstone stamped long.MaxValue and became permanently unwritable. There is no
such ambiguity when the row and the item are the same thing.
FetchAsync(long, CancellationToken)
Fetches a collection of items for the given key.
public Task<DatabaseResult<IReadOnlyList<CharacterItemData>>> FetchAsync(long characterId, CancellationToken cancellationToken = default)
Parameters
characterIdlongcancellationTokenCancellationTokenToken to cancel the operation.
Returns
PersistAsync(CharacterItemData, CancellationToken)
Persists the provided data.
public Task<DatabaseResult<long>> PersistAsync(CharacterItemData item, CancellationToken cancellationToken = default)
Parameters
itemCharacterItemDatacancellationTokenCancellationTokenToken to cancel the operation.
Returns
PersistAsync(IEnumerable<CharacterItemData>, CancellationToken)
Persists the provided items.
public Task<DatabaseResult<BulkWriteResult>> PersistAsync(IEnumerable<CharacterItemData> items, CancellationToken cancellationToken = default)
Parameters
itemsIEnumerable<CharacterItemData>Items to persist.
cancellationTokenCancellationTokenToken to cancel the operation.
Returns
- Task<DatabaseResult<BulkWriteResult>>
What the write actually did, or a failure.
Remarks
A successful result does not mean every supplied row was written. Batched writes are version-gated and the service filters what it cannot act on, so the outcome carries counts rather than a bare boolean — see BulkWriteResult, which explains which discrepancies a caller should care about and which are routine.
SaveSnapshotAsync(long, IReadOnlyCollection<ItemContainerType>, IEnumerable<CharacterItemData>, CancellationToken)
Replaces every item this character owns with an authoritative snapshot of the server's in-memory state.
public Task<DatabaseResult<IReadOnlyList<CharacterItemIdAssignment>>> SaveSnapshotAsync(long characterId, IReadOnlyCollection<ItemContainerType> containers, IEnumerable<CharacterItemData> items, CancellationToken cancellationToken = default)
Parameters
characterIdlongThe character whose items are being replaced.
containersIReadOnlyCollection<ItemContainerType>Which containers this snapshot speaks for. Rows in a container NOT listed here are left untouched, so a caller that could only read two of the three containers does not prune the third. Listing a container and supplying none of its items is the legitimate way to say "this container is empty"; omitting it says "I do not know".
itemsIEnumerable<CharacterItemData>Every item the character holds in
containers.cancellationTokenCancellationTokenToken to cancel the operation.
Returns
- Task<DatabaseResult<IReadOnlyList<CharacterItemIdAssignment>>>
The identity assigned to each row that was supplied without one.
Remarks
The backstop for the incremental per-item writes, which can be silently rejected (a stale version, a dropped async work item, a handler that returned early). Without it, a rejection meant permanent loss at the next login; with it, the worst case is a glitch that survives until the next save tick.
Every row for the character is deleted and re-inserted. Existing identities are
supplied explicitly and preserved, so an item keeps its number; rows whose id is zero
draw a new one and are reported back through the return value. Deleting first is what
makes the statement immune to the (character_id, container, slot) unique index:
two items swapping slots have no intermediate state in which both hold the same one.
Deliberately NOT version-gated. Version gating is the mechanism that makes an incremental write disappear, so gating the backstop as well would defeat its purpose. It is safe because the snapshot is authoritative by construction: it states, for one character at one instant, exactly which items exist and where they are. It cannot mint an item, and the delete cannot lose one, because every row it removes is a row the server has just re-stated or believes does not exist.
ORDERING REQUIREMENT: the caller must enqueue this through the same per-character key as the incremental writes, so the two are serialised FIFO — otherwise an in-flight snapshot can land after a newer incremental write and roll it back. It must also only be issued by a server that currently owns the character's session; there is no session guard here.