Table of Contents

Interface ICharacterService

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

Service interface for managing character entities in the database. Handles core character operations including creation, retrieval, updates, and deletion.

public interface ICharacterService : ICountByKeyAction<string>, IDeleteByKeyVersionedAction<long>, IFetchByKeyAction<long, CharacterData?>, IFetchByKeyAction<string, CharacterData?>, IFetchManyByKeyAction<string, CharacterData>, IPersistAction<CharacterData>
Inherited Members

Remarks

All write operations (Create*, Persist*, Delete*, Set*, Update*) in this service use execution strategies to ensure transient database failures are automatically retried according to the retry policy configured on the DbContext. This is critical because ExecuteSqlRawAsync and SaveChangesAsync do not automatically retry on transient failures without an execution strategy wrapper. BaseService provides execution wrappers for retry and centralized exception mapping; explicit transactions are used only when a write requires multiple database statements.

All methods return DatabaseResult or DatabaseResult<T> to provide structured error information through the DatabaseException system, helping distinguish between: - Validation failures (invalid parameters) - Business rule violations (name already exists) - Database errors (connection issues, constraint violations, timeouts) - Entity not found errors - Unexpected runtime errors

Write operations prefer single-statement SQL (UPDATE/INSERT/DELETE, UPSERT/CTEs) to preserve atomicity and avoid race conditions when multiple servers or clients modify data simultaneously. When more than one database statement is unavoidable, the implementation uses an explicit transaction wrapper.

Methods

AnyOnlineAsync(string, CancellationToken)

Checks whether any non-deleted character on the given account is currently online.

Task<DatabaseResult<bool>> AnyOnlineAsync(string account, CancellationToken cancellationToken = default)

Parameters

account string

The account name.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<bool>>

A DatabaseResult<T> containing true if at least one character is online, false otherwise.

ClearCombatLoggedAsync(long, CancellationToken)

Clears the combat-logout flag on a character.

Task<DatabaseResult> ClearCombatLoggedAsync(long characterId, CancellationToken cancellationToken = default)

Parameters

characterId long

Character to clear.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

Remarks

Used when the scene server that held a character's body is judged to be gone, so the character is no longer waiting for a body that will never be handed back.

CreateCharacterAsync(CharacterData, CancellationToken)

Creates a new character in the database.

Task<DatabaseResult<long>> CreateCharacterAsync(CharacterData characterData, CancellationToken cancellationToken = default)

Parameters

characterData CharacterData

The character data to create.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<long>>

A DatabaseResult<T> containing the newly inserted character ID on success, or a failure with AlreadyExists if the name is taken, ValidationError for invalid input, or DatabaseError for unexpected failures.

Remarks

Uses a single-statement SQL insert (CTE-based) with execution strategy wrapping to ensure transient database failures are automatically retried. Character names are stored with a lowercase version for case-insensitive uniqueness.

FetchAsync(string, bool?, CancellationToken)

Task<DatabaseResult<CharacterData?>> FetchAsync(string characterName, bool? selected, CancellationToken cancellationToken = default)

Parameters

characterName string
selected bool?
cancellationToken CancellationToken

Returns

Task<DatabaseResult<CharacterData?>>

FetchByAccountAsync(string, bool?, CancellationToken)

Fetches a character by account name with an optional selected filter.

Task<DatabaseResult<CharacterData?>> FetchByAccountAsync(string accountName, bool? selected, CancellationToken cancellationToken = default)

Parameters

accountName string

The account name.

selected bool?

If provided, filters by the selected status.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<CharacterData?>>

A DatabaseResult<T> containing the character data, or null if not found.

FetchByAccountAsync(string, CancellationToken)

Fetches a character by account name. Returns the first matching character.

Task<DatabaseResult<CharacterData?>> FetchByAccountAsync(string accountName, CancellationToken cancellationToken = default)

Parameters

accountName string

The account name.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<CharacterData?>>

A DatabaseResult<T> containing the character data, or null if not found.

FetchInWorldCharacterAsync(string, CancellationToken)

Returns the account's character that currently holds a session, if any.

Task<DatabaseResult<CharacterData?>> FetchInWorldCharacterAsync(string account, CancellationToken cancellationToken = default)

Parameters

account string

Account to inspect.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult<CharacterData?>>

The in-world character, or null when the account has none.

Remarks

Unlike AnyOnlineAsync(string, CancellationToken) this counts combat-logout bodies as well, because the caller needs to know a body exists in order to refuse switching away from it. An account may only ever have one character in the world, so at most one row matches.

FetchNamesAsync(IReadOnlyList<long>, CancellationToken)

Fetches a character by name with an optional selected filter.

Task<DatabaseResult<IReadOnlyList<CharacterNameData>>> FetchNamesAsync(IReadOnlyList<long> characterIds, CancellationToken cancellationToken = default)

Parameters

characterIds IReadOnlyList<long>

Characters to resolve. Duplicates and non-positive IDs are ignored.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult<IReadOnlyList<CharacterNameData>>>

A DatabaseResult<T> containing the character data, or null if not found.

Remarks

For labelling rows in lists shown to other players — the dungeon finder's instance list, principally. Projects to ID and name only, and is bounded internally as well as by its caller, because it answers a request a client controls the timing of.

IDs that do not resolve are simply absent from the result rather than being reported; a caller showing a list has to cope with a missing name anyway, since a character can be deleted between the list being built and the names being read.

FetchSelectedCharactersByAccountsAsync(List<string>, int, CancellationToken)

Retrieves the selected character for each of the specified accounts in batches.

Task<DatabaseResult<IReadOnlyList<CharacterData>>> FetchSelectedCharactersByAccountsAsync(List<string> accounts, int maxBatchSize = 1000, CancellationToken cancellationToken = default)

Parameters

accounts List<string>

List of account names to query.

maxBatchSize int

Maximum number of accounts per database round-trip (500–2500).

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult<IReadOnlyList<CharacterData>>>

A list of selected CharacterData, one per account that has a selected character.

FetchUnownedSessionsAsync(IReadOnlyList<CharacterSessionLeaseData>, CancellationToken)

Returns the subset of leases whose sessions the database no longer attributes to the supplied owner — that is, the claims the caller has lost.

Task<DatabaseResult<IReadOnlyList<long>>> FetchUnownedSessionsAsync(IReadOnlyList<CharacterSessionLeaseData> leases, CancellationToken cancellationToken = default)

Parameters

leases IReadOnlyList<CharacterSessionLeaseData>

Ownership triples the caller believes it holds.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult<IReadOnlyList<long>>>

Character IDs no longer owned by the supplied server/token, including rows that have been deleted.

Remarks

RefreshSessionLeasesAsync(IReadOnlyList<CharacterSessionLeaseData>, CancellationToken) reports only how many rows it refreshed, so a short count says a claim was lost without saying which. This resolves that, and is meant to be called only on the short-count path: it is a diagnostic read, not part of the refresh hot path.

A character reported here is being simulated by a server that can no longer persist it. The caller must evict it locally; see PersistOwnedAsync(CharacterData, CharacterSessionLeaseData, CancellationToken).

PersistOwnedAsync(CharacterData, CharacterSessionLeaseData, CancellationToken)

Persists a character row, requiring that the caller still holds its session claim.

Task<DatabaseResult> PersistOwnedAsync(CharacterData characterData, CharacterSessionLeaseData ownership, CancellationToken cancellationToken = default)

Parameters

characterData CharacterData

Snapshot to persist. Its Version must exceed the stored version.

ownership CharacterSessionLeaseData

The claim this server holds, as returned by TryClaimAsync(long, long, CancellationToken).

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

Success when written; Forbidden when the claim is no longer held; StaleState when a newer version is already stored; NotFound when the row is gone.

Remarks

The claim taken by TryClaimAsync(long, long, CancellationToken) gates who may load a character. Without this method nothing gated who may write one: the plain PersistAsync is guarded only by the monotonic Version, so a server whose lease lapsed while it was still running kept saving a character another server had legitimately claimed — and reliably won, because its version counter had been climbing for the whole session while the new owner started again from the persisted row. The claim was advisory on the write path, which is what made a lease lapse corrupting rather than merely untidy.

Ownership is verified in the same statement as the write, so there is no window between checking and writing. A caller that no longer owns the row gets Forbidden and must stop simulating the character rather than retrying — the current owner's state is authoritative, and replaying a stale snapshot over it would destroy exactly the progress this refuses to overwrite.

RefreshSessionLeaseAsync(long, long, Guid, CancellationToken)

Refreshes the session lease for an owned online character.

Task<DatabaseResult> RefreshSessionLeaseAsync(long characterId, long ownerServerId, Guid ownerToken, CancellationToken cancellationToken = default)

Parameters

characterId long
ownerServerId long
ownerToken Guid
cancellationToken CancellationToken

Returns

Task<DatabaseResult>

RefreshSessionLeasesAsync(IReadOnlyList<CharacterSessionLeaseData>, CancellationToken)

Refreshes the session lease for many owned online characters in a single round trip.

Task<DatabaseResult<int>> RefreshSessionLeasesAsync(IReadOnlyList<CharacterSessionLeaseData> leases, CancellationToken cancellationToken = default)

Parameters

leases IReadOnlyList<CharacterSessionLeaseData>

Ownership triples to refresh. Invalid entries are skipped.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult<int>>

The number of leases actually refreshed.

Remarks

Session liveness must not depend on save throughput. Refreshing one character per round trip inside the periodic save loop meant that on a busy shard with a slow database the characters at the tail of the loop could exceed the lease duration between refreshes and become claimable while still online. This performs the whole population in one statement, so the cost is independent of how many characters are resident.

Each entry is verified against the stored owner server and token, so a server that no longer owns a session silently refreshes nothing rather than extending the current owner's lease.

ReleaseAsync(long, long, Guid, CancellationToken)

Releases an online character back to offline and clears ownership in a single step.

Task<DatabaseResult> ReleaseAsync(long characterId, long ownerServerId, Guid ownerToken, CancellationToken cancellationToken = default)

Parameters

characterId long
ownerServerId long
ownerToken Guid
cancellationToken CancellationToken

Returns

Task<DatabaseResult>

RollbackChannelSwitchAsync(long, DateTime, CancellationToken)

Restores a channel-switch cooldown claimed by TryBeginChannelSwitchAsync(long, TimeSpan, CancellationToken) for a switch that did not happen.

Task<DatabaseResult> RollbackChannelSwitchAsync(long characterId, DateTime previousUtc, CancellationToken cancellationToken = default)

Parameters

characterId long

Character whose claim is being released.

previousUtc DateTime

The value returned by the claim, restored as-is.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

Remarks

The claim has to be taken before the transfer, because it is the last thing that can refuse the request — but the transfer can still fail after it: the character enters combat during the validation, the connection goes away, or the scene server's main-thread queue rejects the hand-off. Leaving the claim in place then charged a player the full cooldown for a switch they were refused, and answered their retry with "you are travelling too often" on top of the refusal they had already been given.

Restores the exact previous value rather than clearing the column, so a player who genuinely switched moments ago still serves out the remainder of that cooldown. Nothing else writes this column, and a character has one session, so the guard below cannot discard a newer legitimate claim.

SetSelectedAsync(string, long, CancellationToken)

Sets the selected character for an account atomically. Deselects all other characters for the account.

Task<DatabaseResult> SetSelectedAsync(string account, long characterId, CancellationToken cancellationToken = default)

Parameters

account string

The account name.

characterId long

The character ID to select.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult>

A DatabaseResult indicating success or containing a DatabaseException on failure.

Remarks

Uses a single atomic UPDATE with conditional logic: SET selected = (id = characterId). This ensures all characters for the account are updated in one operation without race conditions. Execution strategy wrapping ensures transient database failures are automatically retried.

TryBeginChannelSwitchAsync(long, TimeSpan, CancellationToken)

Atomically claims a character's channel-switch cooldown window: succeeds and stamps the character only if it has not switched within cooldown.

Task<DatabaseResult<DateTime?>> TryBeginChannelSwitchAsync(long characterId, TimeSpan cooldown, CancellationToken cancellationToken = default)

Parameters

characterId long

Character attempting the switch.

cooldown TimeSpan

Minimum interval between switches.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult<DateTime?>>

The timestamp the claim replaced when the switch may proceed, so a caller that then fails to perform the transfer can put it back with RollbackChannelSwitchAsync(long, DateTime, CancellationToken); null when the character is still on cooldown and nothing was stamped.

Remarks

A channel switch releases the character and drops the connection, so the client comes back through the world server on a fresh connection id — very possibly to a different scene server. Any cooldown held in memory is therefore erased by the switch itself, which left the limit applying only to switches that were refused. The character row is the only state that survives the hop.

Check and stamp are one statement so two scene servers cannot both conclude the cooldown has elapsed. Deliberately not version-gated: this is a rate limit, not gameplay state, and it must not lose to — or interfere with — a concurrent save.

TryClaimAsync(long, long, CancellationToken)

Attempts to claim ownership of a character session (Offline → Online).

Task<DatabaseResult<Guid>> TryClaimAsync(long characterId, long ownerServerId, CancellationToken cancellationToken = default)

Parameters

characterId long
ownerServerId long
cancellationToken CancellationToken

Returns

Task<DatabaseResult<Guid>>

Remarks

A claim is permitted if the character is offline or the previous owner's lease has expired. On success, returns a new session owner token that must be presented for subsequent operations.

UpdatePositionAsync(long, float, float, float, float, float, float, float, CancellationToken)

Updates the position and rotation of a character atomically.

Task<DatabaseResult> UpdatePositionAsync(long characterId, float x, float y, float z, float rotX, float rotY, float rotZ, float rotW, CancellationToken cancellationToken = default)

Parameters

characterId long

The character ID.

x float

The X coordinate.

y float

The Y coordinate.

z float

The Z coordinate.

rotX float

The rotation X component.

rotY float

The rotation Y component.

rotZ float

The rotation Z component.

rotW float

The rotation W component.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult>

A DatabaseResult indicating success or containing a DatabaseException on failure.

Remarks

Uses atomic UPDATE to set all position and rotation components in one operation. Updates last_saved timestamp automatically. Execution strategy wrapping ensures transient database failures are automatically retried.

UpdateSceneAsync(long, long, string, long, CancellationToken)

Updates the routing information for a character atomically.

Task<DatabaseResult> UpdateSceneAsync(long characterId, long worldServerId, string sceneName, long sceneHandle, CancellationToken cancellationToken = default)

Parameters

characterId long

The character ID.

worldServerId long

The world server the character is being routed through.

sceneName string

The scene name.

sceneHandle long

The scene handle.

cancellationToken CancellationToken

Token to cancel the operation.

Returns

Task<DatabaseResult>

A DatabaseResult indicating success or containing a DatabaseException on failure.

Remarks

Uses atomic UPDATE to set world_server_id, scene_name and scene_handle in one operation. All three are written together because the Scene Server matches an incoming character against its loaded scene instances on the full (world_server_id, scene_name, scene_handle) tuple — persisting the scene half while leaving world_server_id stale makes that lookup reject the character as mismatched. Updates last_saved timestamp automatically. Execution strategy wrapping ensures transient database failures are automatically retried.