using NeonSprawl.Server.Game.Factions;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Rewards;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Encounters;
///
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
/// Combat defeat wiring: (NEO-106).
/// NEO-109 telemetry hook sites: (encounter_complete, reward_attribution),
/// (encounter_complete_denied).
///
public static class EncounterCompletionOperations
{
private static readonly EncounterGrantApplied[] EmptyGrants = [];
///
/// When all required targets are defeated and the encounter is not yet completed, applies
/// 's reward-table fixed grants then marks completion.
///
public static EncounterCompletionResult TryCompleteAndGrant(
string playerId,
string encounterId,
IEncounterDefinitionRegistry encounterRegistry,
IRewardTableDefinitionRegistry rewardTableRegistry,
IEncounterProgressStore progressStore,
IEncounterCompletionStore completionStore,
IEncounterCompleteEventStore completeEventStore,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
IQuestDefinitionRegistry questRegistry,
IPlayerQuestStateStore questProgressStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
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(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.
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `encounter_complete` ---
// TODO(E9.M1): catalog emit — once per player+encounter on successful grant + completion mark commit.
// Planned payload: playerId, encounterId, rewardTableId, completedAt, idempotencyKey, grantedItems summary
// (EncounterCompleteEvent). No ingest or ILogger here (comments-only). EncounterCombatWiring delegates here.
// --- Telemetry hook site (NEO-109): future E9.M1 catalog event `reward_attribution` ---
// TODO(E9.M1): catalog emit — once per successful completion (batch attribution, not per TryAddStack).
// Planned payload: playerId, encounterId, rewardTableId, grantedItems[] (EncounterGrantApplied batch).
// Inventory layer still emits item_created per stack via PlayerInventoryOperations (NEO-56).
QuestObjectiveWiring.TryProcessEncounterCompleteSuccess(
normalizedPlayerId,
normalizedEncounterId,
questRegistry,
questProgressStore,
inventoryStore,
itemRegistry,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider);
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 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 — wire at each TryCompleteAndGrant Deny() call site
// (this helper has reasonCode only; callers hold playerId + encounterId). No ingest or ILogger here.
return new EncounterCompletionResult(
Success: false,
ReasonCode: reasonCode,
GrantsApplied: EmptyGrants,
CompleteEvent: null,
CompletedAt: null);
}
}