neon-sprawl/server/NeonSprawl.Server/Game/Encounters/EncounterCompletionOperatio...

180 lines
7.0 KiB
C#

using NeonSprawl.Server.Game.Items;
namespace NeonSprawl.Server.Game.Encounters;
/// <summary>
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
/// Combat defeat wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
/// </summary>
public static class EncounterCompletionOperations
{
private static readonly EncounterGrantApplied[] EmptyGrants = [];
/// <summary>
/// When all required targets are defeated and the encounter is not yet completed, applies
/// <paramref name="encounterId"/>'s reward-table fixed grants then marks completion.
/// </summary>
public static EncounterCompletionResult TryCompleteAndGrant(
string playerId,
string encounterId,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
TimeProvider timeProvider)
{
if (!encounterRegistry.TryNormalizeKnown(encounterId, out var normalizedEncounterId) ||
!encounterRegistry.TryGetDefinition(normalizedEncounterId, out var encounterDefinition))
{
return Deny(EncounterCompletionReasonCodes.UnknownEncounter);
}
if (completionStore.IsCompleted(playerId, normalizedEncounterId))
{
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
if (!EncounterProgressOperations.IsAllRequiredTargetsDefeated(
playerId,
normalizedEncounterId,
encounterRegistry,
progressStore))
{
return Deny(EncounterCompletionReasonCodes.NotReady);
}
if (!rewardTableRegistry.TryGetDefinition(encounterDefinition.RewardTableId, out var rewardTable))
{
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
var grants = rewardTable.FixedGrants;
if (grants.Count == 0)
{
// Prototype catalog gates prevent empty tables at startup; dedicated reason if zero-grant tables ship later.
return Deny(EncounterCompletionReasonCodes.UnknownRewardTable);
}
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
{
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
var simulated = snapshot.Clone();
foreach (var grant in grants)
{
if (!PlayerInventoryOperations.TrySimulateAddStack(
ref simulated,
grant.ItemId,
grant.Quantity,
itemRegistry))
{
return Deny(EncounterCompletionReasonCodes.InventoryFull);
}
}
var applied = new List<EncounterGrantApplied>(grants.Count);
foreach (var grant in grants)
{
var addOutcome = PlayerInventoryOperations.TryAddStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.InventoryStoreMissing);
}
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(MapInventoryReason(addOutcome.ReasonCode));
}
applied.Add(new EncounterGrantApplied(grant.ItemId, grant.Quantity));
}
var completedAt = timeProvider.GetUtcNow();
if (!completionStore.TryMarkCompleted(playerId, normalizedEncounterId, completedAt))
{
CompensatingRemoveGrants(playerId, applied, itemRegistry, inventoryStore);
return Deny(EncounterCompletionReasonCodes.AlreadyCompleted);
}
var normalizedPlayerId = EncounterProgressIds.NormalizePlayerId(playerId);
var completeEvent = new EncounterCompleteEvent(
normalizedPlayerId,
normalizedEncounterId,
rewardTable.Id,
applied,
completedAt,
MakeIdempotencyKey(normalizedPlayerId, normalizedEncounterId));
_ = completeEventStore.TryRecord(completeEvent);
// TODO(E7.M2): reward_delivery — QuestRewardBundle router consumes EncounterCompleteEvent
// (playerId, encounterId, idempotencyKey, grantedItemSummary) → idempotent RewardDeliveryEvent.
// Prototype quest credit: contract_handoff_token item loot + this event record; full router: E7.M2.
// TODO(E9.M1): catalog emit — encounter_complete on successful grant commit (NEO-109).
return new EncounterCompletionResult(
Success: true,
ReasonCode: null,
GrantsApplied: applied,
CompleteEvent: completeEvent,
CompletedAt: completedAt);
}
private static string MapInventoryReason(string? reasonCode) =>
reasonCode switch
{
PlayerInventoryReasonCodes.InventoryFull => EncounterCompletionReasonCodes.InventoryFull,
PlayerInventoryReasonCodes.InvalidItem => EncounterCompletionReasonCodes.InvalidItem,
PlayerInventoryReasonCodes.InvalidQuantity => EncounterCompletionReasonCodes.InvalidQuantity,
null => EncounterCompletionReasonCodes.InventoryFull,
_ => reasonCode,
};
private static string MakeIdempotencyKey(string normalizedPlayerId, string normalizedEncounterId) =>
$"{normalizedPlayerId}:{normalizedEncounterId}";
private static void CompensatingRemoveGrants(
string playerId,
IReadOnlyList<EncounterGrantApplied> grants,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore)
{
for (var i = grants.Count - 1; i >= 0; i--)
{
var grant = grants[i];
_ = PlayerInventoryOperations.TryRemoveStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
}
}
private static EncounterCompletionResult Deny(string reasonCode)
{
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete_denied` ---
// TODO(E9.M1): catalog emit — structured deny with reasonCode (EncounterCompletionReasonCodes).
// Planned payload: playerId, encounterId, reasonCode. No ingest or ILogger here (comments-only).
return new EncounterCompletionResult(
Success: false,
ReasonCode: reasonCode,
GrantsApplied: EmptyGrants,
CompleteEvent: null,
CompletedAt: null);
}
}