Table of Contents

Class CharacterItemService

Namespace
FishMMO.Database.Npgsql.Services
Assembly
FishMMO-DB.dll

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

dbContextFactory INpgsqlDbContextFactory

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

characterId long
incomingVersion long

The authoritative, monotonic version for this delete operation.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult>

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

characterId long

The character that owns the item. Checked, so one character cannot delete another's row.

itemId long

The item's identity.

incomingVersion long

The authoritative, monotonic version for this delete.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult>

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

characterId long
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<IReadOnlyList<CharacterItemData>>>

PersistAsync(CharacterItemData, CancellationToken)

Persists the provided data.

public Task<DatabaseResult<long>> PersistAsync(CharacterItemData item, CancellationToken cancellationToken = default)

Parameters

item CharacterItemData
cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<long>>

PersistAsync(IEnumerable<CharacterItemData>, CancellationToken)

Persists the provided items.

public Task<DatabaseResult<BulkWriteResult>> PersistAsync(IEnumerable<CharacterItemData> items, CancellationToken cancellationToken = default)

Parameters

items IEnumerable<CharacterItemData>

Items to persist.

cancellationToken CancellationToken

Token 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

characterId long

The character whose items are being replaced.

containers IReadOnlyCollection<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".

items IEnumerable<CharacterItemData>

Every item the character holds in containers.

cancellationToken CancellationToken

Token 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.