Service that handles character creation and deletion transactions. It validates requests, enforces policies, allocates IDs, builds initial inventories and items, reserves unique slots (including class capacity), persists records via a unit-of-work, and publishes post-commit events.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Hexagon.V2.Domain;
using Hexagon.V2.Kernel;
using Hexagon.V2.Kernel.Events;
using Hexagon.V2.Kernel.Policies;
using Hexagon.V2.Kernel.Schema;
using Hexagon.V2.Persistence;
namespace Hexagon.V2.Application;
/// <summary>
/// Owns the complete character creation/deletion transaction. It publishes only
/// immutable post-commit facts and never trusts an account identifier from a request.
/// </summary>
public sealed class CharacterService
{
private const string MainInventoryRole = InventoryRoles.Main;
private const string BagInventoryRole = InventoryRoles.Bag;
private const string ClassCapacityReservationNamespace = "hexagon.class-capacity";
private readonly DomainRepositories _repositories;
private readonly CompiledSchema _schema;
private readonly ICharacterModelCatalog _models;
private readonly ICharacterStateFactory _stateFactory;
private readonly IReadOnlyList<ICharacterInitializer> _initializers;
private readonly IAggregateIdGenerator _ids;
private readonly IHexClock _clock;
private readonly InventoryLayoutService _layout;
private readonly PolicyPipeline<CharacterCreationContext> _creationPolicy;
private readonly PolicyPipeline<CharacterDeletionContext> _deletionPolicy;
private readonly PostCommitEventBus<CharacterCreatedEvent> _createdEvents;
private readonly PostCommitEventBus<CharacterDeletedEvent> _deletedEvents;
private readonly int _inventoryWidth;
private readonly int _inventoryHeight;
public CharacterService(
DomainRepositories repositories,
CompiledSchema schema,
ICharacterModelCatalog models,
ICharacterStateFactory stateFactory,
IEnumerable<ICharacterInitializer> initializers,
IAggregateIdGenerator ids,
IHexClock clock,
InventoryLayoutService layout,
PolicyPipeline<CharacterCreationContext> creationPolicy,
PolicyPipeline<CharacterDeletionContext> deletionPolicy,
PostCommitEventBus<CharacterCreatedEvent>? createdEvents = null,
PostCommitEventBus<CharacterDeletedEvent>? deletedEvents = null,
int inventoryWidth = 8,
int inventoryHeight = 6 )
{
_repositories = repositories ?? throw new ArgumentNullException( nameof(repositories) );
_schema = schema ?? throw new ArgumentNullException( nameof(schema) );
_models = models ?? throw new ArgumentNullException( nameof(models) );
_stateFactory = stateFactory ?? throw new ArgumentNullException( nameof(stateFactory) );
_initializers = (initializers ?? throw new ArgumentNullException( nameof(initializers) ))
.OrderBy( initializer => initializer.Order )
.ThenBy( initializer => initializer.Id, StringComparer.Ordinal )
.ToArray();
_ids = ids ?? throw new ArgumentNullException( nameof(ids) );
_clock = clock ?? throw new ArgumentNullException( nameof(clock) );
_layout = layout ?? throw new ArgumentNullException( nameof(layout) );
_creationPolicy = creationPolicy ?? throw new ArgumentNullException( nameof(creationPolicy) );
_deletionPolicy = deletionPolicy ?? throw new ArgumentNullException( nameof(deletionPolicy) );
_createdEvents = createdEvents ?? new PostCommitEventBus<CharacterCreatedEvent>();
_deletedEvents = deletedEvents ?? new PostCommitEventBus<CharacterDeletedEvent>();
if ( inventoryWidth <= 0 ) throw new ArgumentOutOfRangeException( nameof(inventoryWidth) );
if ( inventoryHeight <= 0 ) throw new ArgumentOutOfRangeException( nameof(inventoryHeight) );
_inventoryWidth = inventoryWidth;
_inventoryHeight = inventoryHeight;
}
public IReadOnlyList<CharacterRecord> ListForAccount( AccountId accountId )
{
// Bounded keyed probes over the dense slot key space; O(1) in store size and
// slot-ordered by construction, replacing a full character-table scan and sort.
var characters = new List<CharacterRecord>();
for ( var slot = 0; slot < CharacterRules.MaximumSlots; slot++ )
{
var slotDocument = _repositories.CharacterSlots.Find( DomainKeys.CharacterSlot( accountId, slot ) );
if ( slotDocument is null ) continue;
var character = _repositories.Characters.Find(
DomainKeys.Character( slotDocument.Value.CharacterId ) );
if ( character is not null ) characters.Add( character.Value );
}
return characters;
}
public ValueTask<OperationResult<CharacterCreationReceipt>> CreateAsync(
AccountId authenticatedAccount,
CharacterCreationRequest request,
CancellationToken cancellationToken = default )
{
ArgumentNullException.ThrowIfNull( request );
return CreateCoreAsync( authenticatedAccount, request, cancellationToken );
}
public async ValueTask<OperationResult<CharacterDeletionReceipt>> DeleteAsync(
AccountId authenticatedAccount,
CharacterId characterId,
CancellationToken cancellationToken = default )
{
var characterDocument = _repositories.Characters.Find( DomainKeys.Character( characterId ) );
if ( characterDocument is null || characterDocument.Value.AccountId != authenticatedAccount )
return OperationResult<CharacterDeletionReceipt>.Failure(
ErrorCode.NotFound, "Character was not found for the authenticated account." );
var character = characterDocument.Value;
var policy = _deletionPolicy.Evaluate( new CharacterDeletionContext( authenticatedAccount, character ) );
if ( policy.Failed )
return OperationResult<CharacterDeletionReceipt>.Failure( policy.Error!.Code, policy.Error.Message );
var inventories = FindOwnedInventoryGraph( character.Id );
var ownedItemIds = inventories
.SelectMany( inventory => inventory.Value.Placements )
.Select( placement => placement.ItemId )
.ToHashSet();
var slotDocument = _repositories.CharacterSlots.Find(
DomainKeys.CharacterSlot( character.AccountId, character.Slot ) );
if ( slotDocument is null )
return OperationResult<CharacterDeletionReceipt>.Failure(
ErrorCode.Conflict, "Character owner-slot guard is missing." );
var lifecycleGuard = _repositories.CharacterLifecycleGuards.Find(
DomainKeys.CharacterLifecycleGuard( character.Id ) );
if ( lifecycleGuard is null )
return OperationResult<CharacterDeletionReceipt>.Failure(
ErrorCode.Conflict, "Character lifecycle guard is missing." );
var ownerIndexes = inventories
.Select( inventory =>
{
var role = inventory.Value.Owner.Kind == InventoryOwnerKind.Character ? MainInventoryRole : BagInventoryRole;
return _repositories.OwnerInventories.Find(
DomainKeys.OwnerInventory( inventory.Value.Owner, role ) );
} )
.Where( document => document is not null )
.Select( document => document! )
.ToArray();
var itemDocuments = ownedItemIds
.Select( itemId => _repositories.Items.Find( DomainKeys.Item( itemId ) ) )
.Where( document => document is not null )
.Select( document => document! )
.ToArray();
var reservations = _repositories.UniqueReservations.All()
.Where( document => document.Value.CharacterId == character.Id )
.ToArray();
var references = _repositories.CharacterReferences.All()
.Where( document => document.Value.CharacterId == character.Id ||
document.Value.RelatedCharacterId == character.Id )
.ToArray();
var relatedGuardDocuments = references
.SelectMany( reference => new[]
{
reference.Value.CharacterId,
reference.Value.RelatedCharacterId
} )
.Where( target => target is not null && target.Value != character.Id )
.Select( target => target!.Value )
.Distinct()
.Select( target => _repositories.CharacterLifecycleGuards.Find(
DomainKeys.CharacterLifecycleGuard( target ) ) )
.Where( document => document is not null )
.Select( document => document! )
.ToArray();
var unitOfWork = _repositories.Provider.BeginUnitOfWork();
unitOfWork.Delete( _repositories.Characters, characterDocument );
unitOfWork.Delete( _repositories.CharacterSlots, slotDocument );
unitOfWork.Delete( _repositories.CharacterLifecycleGuards, lifecycleGuard );
foreach ( var inventory in inventories )
unitOfWork.Delete( _repositories.Inventories, inventory );
foreach ( var ownerIndex in ownerIndexes )
unitOfWork.Delete( _repositories.OwnerInventories, ownerIndex );
foreach ( var itemDocument in itemDocuments )
unitOfWork.Delete( _repositories.Items, itemDocument );
foreach ( var reservation in reservations )
unitOfWork.Delete( _repositories.UniqueReservations, reservation );
foreach ( var reference in references )
unitOfWork.Delete( _repositories.CharacterReferences, reference );
foreach ( var relatedGuard in relatedGuardDocuments )
{
var editor = unitOfWork.Edit( _repositories.CharacterLifecycleGuards, relatedGuard );
if ( editor is null )
{
await unitOfWork.DisposeAsync();
return OperationResult<CharacterDeletionReceipt>.Failure(
ErrorCode.Conflict, "Related character lifecycle guard changed." );
}
editor.Replace( editor.Value with
{
ReferenceRevision = checked(editor.Value.ReferenceRevision + 1)
} );
unitOfWork.Save( editor );
}
var committed = await unitOfWork.CommitAsync( cancellationToken );
await unitOfWork.DisposeAsync();
if ( !committed.Succeeded )
return PersistenceResultMapping.Failure<CharacterDeletionReceipt>( committed.Error! );
var receipt = new CharacterDeletionReceipt( character.Id, character.AccountId, committed.Value! );
_deletedEvents.Publish( new CharacterDeletedEvent( character.Id, character.AccountId, committed.Value! ) );
return OperationResult<CharacterDeletionReceipt>.Success( receipt );
}
private async ValueTask<OperationResult<CharacterCreationReceipt>> CreateCoreAsync(
AccountId authenticatedAccount,
CharacterCreationRequest request,
CancellationToken cancellationToken )
{
var allowedFields = _schema.CharacterFields.All
.Where( definition => definition.ShowInCreation )
.Select( definition => definition.Id )
.ToHashSet( StringComparer.Ordinal );
var basicValidation = CharacterRules.ValidateCreationRequest( request, allowedFields );
if ( basicValidation.Failed )
return OperationResult<CharacterCreationReceipt>.Failure(
basicValidation.Error!.Code, basicValidation.Error.Message );
var fieldValidation = ValidateCreationFields( request );
if ( fieldValidation.Failed )
return OperationResult<CharacterCreationReceipt>.Failure(
fieldValidation.Error!.Code, fieldValidation.Error.Message );
if ( !_schema.Factions.TryGet( request.Faction.Value, out var faction ) )
return OperationResult<CharacterCreationReceipt>.Failure( ErrorCode.UnknownDefinition, "Unknown faction." );
ClassId? selectedClass = request.Class;
Kernel.Definitions.ClassDefinition? selectedClassDefinition = null;
if ( selectedClass is null && faction!.DefaultClassId is not null )
selectedClass = new ClassId( faction.DefaultClassId );
if ( selectedClass is not null )
{
if ( !_schema.Classes.TryGet( selectedClass.Value.Value, out selectedClassDefinition ) ||
!string.Equals( selectedClassDefinition!.FactionId, request.Faction.Value, StringComparison.Ordinal ) )
return OperationResult<CharacterCreationReceipt>.Failure( ErrorCode.PolicyDenied, "Class is not valid for the selected faction." );
}
if ( !_models.IsAllowed( request.Model, request.Faction, selectedClass ) )
return OperationResult<CharacterCreationReceipt>.Failure( ErrorCode.PolicyDenied, "Model is not allowed for the selected faction and class." );
var normalizedRequest = request with
{
Name = request.Name.Trim(),
Description = request.Description.Trim(),
Class = selectedClass,
Fields = new Dictionary<string, CreationValue>( request.Fields, StringComparer.Ordinal )
};
var ownedCharacters = ListForAccount( authenticatedAccount );
var slot = CharacterRules.FindLowestFreeSlot( ownedCharacters );
if ( slot < 0 )
return OperationResult<CharacterCreationReceipt>.Failure(
ErrorCode.Conflict,
$"All {CharacterRules.MaximumSlots} character slots are occupied for this account." );
var now = _clock.UtcNow;
var context = new CharacterCreationContext( authenticatedAccount, normalizedRequest, slot, now );
var policy = _creationPolicy.Evaluate( context );
if ( policy.Failed )
return OperationResult<CharacterCreationReceipt>.Failure( policy.Error!.Code, policy.Error.Message );
var state = _stateFactory.Create( context );
if ( state.Failed )
return OperationResult<CharacterCreationReceipt>.Failure( state.Error!.Code, state.Error.Message );
if ( state.Value.StartingBalance < 0 )
return OperationResult<CharacterCreationReceipt>.Failure( ErrorCode.InvalidArgument, "Starting balance cannot be negative." );
var stateType = ValidateTypedPayload( state.Value.State );
if ( stateType.Failed )
return OperationResult<CharacterCreationReceipt>.Failure( stateType.Error!.Code, stateType.Error.Message );
var contributions = new List<CharacterInitializerContribution>();
foreach ( var initializer in _initializers )
{
var contribution = initializer.Build( context, state.Value );
if ( contribution.Failed )
return OperationResult<CharacterCreationReceipt>.Failure(
contribution.Error!.Code,
$"Initializer '{initializer.Id}' failed: {contribution.Error.Message}" );
contributions.Add( contribution.Value );
}
var characterId = _ids.NewCharacterId();
var inventoryId = _ids.NewInventoryId();
var character = new CharacterRecord
{
Id = characterId,
AccountId = authenticatedAccount,
Slot = slot,
Name = normalizedRequest.Name,
Description = normalizedRequest.Description,
Model = normalizedRequest.Model,
Faction = normalizedRequest.Faction,
Class = selectedClass,
Balance = state.Value.StartingBalance,
CreatedAt = now,
LastPlayedAt = now,
SchemaState = state.Value.State.DeepCopy(),
Revision = 0
};
var mainInventory = new InventoryRecord
{
Id = inventoryId,
Owner = InventoryOwner.Character( characterId ),
Width = _inventoryWidth,
Height = _inventoryHeight,
Placements = Array.Empty<InventoryPlacement>(),
Revision = 0
};
var items = new List<ItemRecord>();
var inventories = new List<InventoryRecord>();
var ownerIndexes = new List<OwnerInventoryRecord>();
var stagedDefinitions = new Dictionary<ItemId, DefinitionId>();
var fill = FillInventory(
mainInventory,
contributions.SelectMany( contribution => contribution.Items ),
items,
inventories,
ownerIndexes,
stagedDefinitions );
if ( fill.Failed )
return OperationResult<CharacterCreationReceipt>.Failure( fill.Error!.Code, fill.Error.Message );
mainInventory = fill.Value;
inventories.Insert( 0, mainInventory );
ownerIndexes.Insert( 0, new OwnerInventoryRecord
{
Role = MainInventoryRole,
Owner = mainInventory.Owner,
InventoryId = mainInventory.Id
} );
var reservationPlans = contributions.SelectMany( contribution => contribution.Reservations ).ToList();
if ( selectedClassDefinition?.Capacity is int classCapacity )
{
var capacitySlot = Enumerable.Range( 0, classCapacity )
.FirstOrDefault( candidate => _repositories.UniqueReservations.Find(
DomainKeys.UniqueReservation(
ClassCapacityReservationNamespace,
$"{selectedClassDefinition.Id}.{candidate:D6}" ) ) is null, -1 );
if ( capacitySlot < 0 )
return OperationResult<CharacterCreationReceipt>.Failure(
ErrorCode.Conflict, $"Class '{selectedClassDefinition.Id}' is at capacity." );
reservationPlans.Add( new UniqueReservationPlan(
ClassCapacityReservationNamespace,
$"{selectedClassDefinition.Id}.{capacitySlot:D6}" ) );
}
var reservationKeys = new HashSet<string>( StringComparer.Ordinal );
foreach ( var reservation in reservationPlans )
{
var key = DomainKeys.UniqueReservation( reservation.Namespace, reservation.Value );
if ( !reservationKeys.Add( key ) || _repositories.UniqueReservations.Find( key ) is not null )
return OperationResult<CharacterCreationReceipt>.Failure(
ErrorCode.Conflict, $"Unique value '{reservation.Namespace}' is already reserved." );
}
var unitOfWork = _repositories.Provider.BeginUnitOfWork();
unitOfWork.Create( _repositories.CharacterSlots, DomainKeys.CharacterSlot( authenticatedAccount, slot ), new CharacterSlotRecord
{
AccountId = authenticatedAccount,
Slot = slot,
CharacterId = character.Id
} );
unitOfWork.Create( _repositories.Characters, DomainKeys.Character( character.Id ), character );
unitOfWork.Create(
_repositories.CharacterLifecycleGuards,
DomainKeys.CharacterLifecycleGuard( character.Id ),
new CharacterLifecycleGuardRecord { CharacterId = character.Id, ReferenceRevision = 0 } );
foreach ( var inventory in inventories )
unitOfWork.Create( _repositories.Inventories, DomainKeys.Inventory( inventory.Id ), inventory );
foreach ( var ownerIndex in ownerIndexes )
unitOfWork.Create(
_repositories.OwnerInventories,
DomainKeys.OwnerInventory( ownerIndex.Owner, ownerIndex.Role ),
ownerIndex );
foreach ( var item in items )
unitOfWork.Create( _repositories.Items, DomainKeys.Item( item.Id ), item );
foreach ( var reservation in reservationPlans )
unitOfWork.Create(
_repositories.UniqueReservations,
DomainKeys.UniqueReservation( reservation.Namespace, reservation.Value ),
new UniqueReservationRecord
{
Namespace = reservation.Namespace,
Value = reservation.Value,
CharacterId = character.Id
} );
var committed = await unitOfWork.CommitAsync( cancellationToken );
await unitOfWork.DisposeAsync();
if ( !committed.Succeeded ) return PersistenceResultMapping.Failure<CharacterCreationReceipt>( committed.Error! );
var committedInventories = inventories.ToArray();
var committedItems = items.ToArray();
var receipt = new CharacterCreationReceipt(
character, mainInventory, committedInventories, committedItems, committed.Value! );
_createdEvents.Publish( new CharacterCreatedEvent(
character, mainInventory, committedInventories, committedItems, committed.Value! ) );
return OperationResult<CharacterCreationReceipt>.Success( receipt );
}
private OperationResult<InventoryRecord> FillInventory(
InventoryRecord destination,
IEnumerable<ItemGrantPlan> grants,
ICollection<ItemRecord> items,
ICollection<InventoryRecord> childInventories,
ICollection<OwnerInventoryRecord> ownerIndexes,
IDictionary<ItemId, DefinitionId> stagedDefinitions )
{
var current = destination;
foreach ( var grant in grants )
{
if ( !_schema.Items.TryGet( grant.Definition.Value, out _ ) )
return OperationResult<InventoryRecord>.Failure(
ErrorCode.UnknownDefinition, $"Initializer requested unknown item '{grant.Definition}'." );
foreach ( var trait in grant.Traits )
{
var traitType = ValidateTypedPayload( trait.Value );
if ( traitType.Failed )
return OperationResult<InventoryRecord>.Failure(
traitType.Error!.Code, $"Trait '{trait.Key}' is invalid: {traitType.Error.Message}" );
}
var item = new ItemRecord
{
Id = _ids.NewItemId(),
Definition = grant.Definition,
Traits = grant.Traits.ToDictionary(
pair => pair.Key,
pair => pair.Value.DeepCopy(),
StringComparer.Ordinal ),
Revision = 0
};
if ( !stagedDefinitions.TryAdd( item.Id, item.Definition ) )
return OperationResult<InventoryRecord>.Failure( ErrorCode.Conflict, "ID generator produced a duplicate item ID." );
var firstFit = _layout.FindFirstFit( current, item, (IReadOnlyDictionary<ItemId, DefinitionId>)stagedDefinitions );
if ( firstFit.Failed )
return OperationResult<InventoryRecord>.Failure( firstFit.Error!.Code, firstFit.Error.Message );
var added = _layout.AddAt(
current, item, firstFit.Value.X, firstFit.Value.Y,
(IReadOnlyDictionary<ItemId, DefinitionId>)stagedDefinitions );
if ( added.Failed )
return OperationResult<InventoryRecord>.Failure( added.Error!.Code, added.Error.Message );
current = added.Value;
items.Add( item );
if ( grant.Bag is null ) continue;
if ( grant.Bag.Width <= 0 || grant.Bag.Height <= 0 )
return OperationResult<InventoryRecord>.Failure( ErrorCode.InvalidArgument, "Bag dimensions must be positive." );
var bagInventory = new InventoryRecord
{
Id = _ids.NewInventoryId(),
Owner = InventoryOwner.ParentItem( item.Id ),
Width = grant.Bag.Width,
Height = grant.Bag.Height,
Placements = Array.Empty<InventoryPlacement>(),
Revision = 0
};
var filledBag = FillInventory(
bagInventory, grant.Bag.Items, items, childInventories, ownerIndexes, stagedDefinitions );
if ( filledBag.Failed ) return filledBag;
childInventories.Add( filledBag.Value );
ownerIndexes.Add( new OwnerInventoryRecord
{
Role = BagInventoryRole,
Owner = filledBag.Value.Owner,
InventoryId = filledBag.Value.Id
} );
}
return OperationResult<InventoryRecord>.Success( current );
}
private OperationResult ValidateCreationFields( CharacterCreationRequest request )
{
foreach ( var definition in _schema.CharacterFields.All.Where( field => field.ShowInCreation ) )
{
if ( !request.Fields.TryGetValue( definition.Id, out var value ) )
{
if ( definition.Required && definition.DefaultValue is null )
return OperationResult.Failure( ErrorCode.InvalidArgument, $"Creation field '{definition.Id}' is required." );
continue;
}
var validType = value.Kind switch
{
CreationValueKind.String => definition.ValueKind == Kernel.Definitions.CharacterFieldValueKind.String,
CreationValueKind.Choice => definition.ValueKind == Kernel.Definitions.CharacterFieldValueKind.Choice,
CreationValueKind.Integer => definition.ValueKind == Kernel.Definitions.CharacterFieldValueKind.Integer,
CreationValueKind.Boolean => definition.ValueKind == Kernel.Definitions.CharacterFieldValueKind.Boolean,
_ => false
};
if ( !validType )
return OperationResult.Failure( ErrorCode.InvalidArgument, $"Creation field '{definition.Id}' has the wrong type." );
}
return OperationResult.Success();
}
private OperationResult ValidateTypedPayload( TypedPayload payload )
{
if ( !_schema.PersistedTypes.TryGet( payload.TypeId.Value, out var registration ) )
return OperationResult.Failure(
ErrorCode.PersistedTypeInvalid, $"Persisted type '{payload.TypeId}' is not registered." );
var registeredVersion = registration!.Version;
if ( payload.TypeVersion <= 0 || payload.TypeVersion > registeredVersion )
return OperationResult.Failure(
ErrorCode.PersistedTypeInvalid,
$"Payload '{payload.TypeId}' version {payload.TypeVersion} is incompatible with registered version {registeredVersion}." );
return OperationResult.Success();
}
private IReadOnlyList<DocumentSnapshot<InventoryRecord>> FindOwnedInventoryGraph( CharacterId characterId )
{
var all = _repositories.Inventories.All().ToArray();
var owned = new List<DocumentSnapshot<InventoryRecord>>();
var itemFrontier = new Queue<ItemId>();
foreach ( var inventory in all.Where( candidate =>
candidate.Value.Owner.Kind == InventoryOwnerKind.Character &&
candidate.Value.Owner.OwnerId == characterId.Value ) )
{
owned.Add( inventory );
foreach ( var placement in inventory.Value.Placements ) itemFrontier.Enqueue( placement.ItemId );
}
var seenInventories = owned.Select( inventory => inventory.Value.Id ).ToHashSet();
while ( itemFrontier.Count > 0 )
{
var parentItem = itemFrontier.Dequeue();
foreach ( var child in all.Where( candidate =>
candidate.Value.Owner.Kind == InventoryOwnerKind.ParentItem &&
candidate.Value.Owner.OwnerId == parentItem.Value ) )
{
if ( !seenInventories.Add( child.Value.Id ) ) continue;
owned.Add( child );
foreach ( var placement in child.Value.Placements ) itemFrontier.Enqueue( placement.ItemId );
}
}
return owned;
}
}