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
accountstringThe account name.
cancellationTokenCancellationTokenToken to cancel the operation.
Returns
- Task<DatabaseResult<bool>>
A DatabaseResult<T> containing
trueif at least one character is online,falseotherwise.
ClearCombatLoggedAsync(long, CancellationToken)
Clears the combat-logout flag on a character.
Task<DatabaseResult> ClearCombatLoggedAsync(long characterId, CancellationToken cancellationToken = default)
Parameters
characterIdlongCharacter to clear.
cancellationTokenCancellationTokenCancellation token.
Returns
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
characterDataCharacterDataThe character data to create.
cancellationTokenCancellationTokenToken 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
characterNamestringselectedbool?cancellationTokenCancellationToken
Returns
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
accountNamestringThe account name.
selectedbool?If provided, filters by the selected status.
cancellationTokenCancellationTokenToken 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
accountNamestringThe account name.
cancellationTokenCancellationTokenToken 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
accountstringAccount to inspect.
cancellationTokenCancellationTokenCancellation token.
Returns
- Task<DatabaseResult<CharacterData?>>
The in-world character, or
nullwhen 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
characterIdsIReadOnlyList<long>Characters to resolve. Duplicates and non-positive IDs are ignored.
cancellationTokenCancellationTokenToken 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
accountsList<string>List of account names to query.
maxBatchSizeintMaximum number of accounts per database round-trip (500–2500).
cancellationTokenCancellationTokenCancellation 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
leasesIReadOnlyList<CharacterSessionLeaseData>Ownership triples the caller believes it holds.
cancellationTokenCancellationTokenCancellation 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
characterDataCharacterDataSnapshot to persist. Its
Versionmust exceed the stored version.ownershipCharacterSessionLeaseDataThe claim this server holds, as returned by TryClaimAsync(long, long, CancellationToken).
cancellationTokenCancellationTokenCancellation 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
characterIdlongownerServerIdlongownerTokenGuidcancellationTokenCancellationToken
Returns
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
leasesIReadOnlyList<CharacterSessionLeaseData>Ownership triples to refresh. Invalid entries are skipped.
cancellationTokenCancellationTokenCancellation 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
characterIdlongownerServerIdlongownerTokenGuidcancellationTokenCancellationToken
Returns
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
characterIdlongCharacter whose claim is being released.
previousUtcDateTimeThe value returned by the claim, restored as-is.
cancellationTokenCancellationTokenCancellation token.
Returns
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
accountstringThe account name.
characterIdlongThe character ID to select.
cancellationTokenCancellationTokenToken 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
characterIdlongCharacter attempting the switch.
cooldownTimeSpanMinimum interval between switches.
cancellationTokenCancellationTokenCancellation 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);
nullwhen 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
characterIdlongownerServerIdlongcancellationTokenCancellationToken
Returns
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
characterIdlongThe character ID.
xfloatThe X coordinate.
yfloatThe Y coordinate.
zfloatThe Z coordinate.
rotXfloatThe rotation X component.
rotYfloatThe rotation Y component.
rotZfloatThe rotation Z component.
rotWfloatThe rotation W component.
cancellationTokenCancellationTokenToken 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
characterIdlongThe character ID.
worldServerIdlongThe world server the character is being routed through.
sceneNamestringThe scene name.
sceneHandlelongThe scene handle.
cancellationTokenCancellationTokenToken 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.