using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Items;
using NeonSprawl.Server.Game.Mastery;
using NeonSprawl.Server.Game.Quests;
using NeonSprawl.Server.Game.Skills;
namespace NeonSprawl.Server.Game.Rewards;
///
/// Applies quest completion grants idempotently (NEO-127).
/// Quest-state wiring: (NEO-128).
/// E9.M1 telemetry hook sites: (NEO-130).
///
public static class RewardRouterOperations
{
private static readonly RewardItemGrantApplied[] EmptyItemGrants = [];
private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = [];
///
/// Applies item + skill XP rows for a quest completion and records
/// when grants succeed.
///
public static RewardDeliveryResult TryDeliverQuestCompletion(
string playerId,
string questId,
QuestRewardBundleRow? bundle,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
if (normalizedPlayerId.Length == 0)
{
return Deny(RewardDeliveryReasonCodes.InvalidPlayerId);
}
var normalizedQuestId = RewardDeliveryIds.NormalizeQuestId(questId);
if (normalizedQuestId.Length == 0)
{
return Deny(RewardDeliveryReasonCodes.InvalidQuestId);
}
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var existingEvent))
{
return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
var itemGrants = bundle?.ItemGrants ?? Array.Empty();
var skillGrants = bundle?.SkillXpGrants ?? Array.Empty();
if (itemGrants.Count > 0)
{
if (!inventoryStore.TryGetSnapshot(playerId, out var snapshot))
{
return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing);
}
var simulated = snapshot.Clone();
foreach (var grant in itemGrants)
{
if (!PlayerInventoryOperations.TrySimulateAddStack(
ref simulated,
grant.ItemId,
grant.Quantity,
itemRegistry))
{
return Deny(RewardDeliveryReasonCodes.InventoryFull);
}
}
}
var appliedItems = new List(itemGrants.Count);
foreach (var grant in itemGrants)
{
var addOutcome = PlayerInventoryOperations.TryAddStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
return Deny(RewardDeliveryReasonCodes.InventoryStoreMissing);
}
if (addOutcome.Kind == PlayerInventoryMutationKind.Denied)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
return Deny(MapInventoryReason(addOutcome.ReasonCode));
}
appliedItems.Add(new RewardItemGrantApplied(grant.ItemId, grant.Quantity));
}
var perksUnlockedByThisCall = new HashSet(StringComparer.Ordinal);
var appliedSkillXp = new List(skillGrants.Count);
foreach (var grant in skillGrants)
{
var perkBeforeGrant = perkStore.GetSnapshot(playerId);
var skillOutcome = SkillProgressionGrantOperations.TryApplyGrant(
playerId,
grant.SkillId,
grant.Amount,
MissionRewardSkillXpConstants.MissionRewardSourceKind,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine);
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.ProgressionStoreMissing)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing);
}
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied)
{
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
var reasonCode = skillOutcome.Response?.ReasonCode ?? RewardDeliveryReasonCodes.UnknownSkill;
return Deny(reasonCode);
}
appliedSkillXp.Add(new RewardSkillXpGrantApplied(grant.SkillId, grant.Amount));
SkillProgressionGrantOperations.CollectPerksUnlockedSince(
perkBeforeGrant,
perkStore.GetSnapshot(playerId),
perksUnlockedByThisCall);
}
// --- Telemetry hook site (NEO-130): future E9.M1 catalog event `unlock_granted` (stub) ---
// TODO(E9.M1): catalog emit — once per applied UnlockGrant row when bundle unlock grants land (pre-production).
// Prototype completionRewardBundle has item + skill XP rows only; no runtime unlock apply in Slice 2.
// Planned payload fields: playerId, questId, unlock id / kind — finalize at E9.M1 wiring time.
// Distinct from `perk_unlock` (PerkUnlockEngine, NEO-49) which may fire as a side effect of skill XP grants.
// E9.M1 wiring: place per-row emits inside the apply loop and/or after TryRecord success only — not on
// TryRecord race-rollback paths (grants applied then reverted below); mirror reward_delivery gating.
var deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent(
normalizedPlayerId,
normalizedQuestId,
deliveredAt,
appliedItems,
appliedSkillXp,
RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId));
if (deliveryStore.TryRecord(deliveryEvent))
{
// --- Telemetry hook site (NEO-130): future E9.M1 catalog event `reward_delivery` ---
// TODO(E9.M1): catalog emit — once per first-time quest completion delivery (TryRecord success).
// Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths.
// Planned payload fields: playerId, questId, deliveredAt, grantedItems (itemId, quantity),
// grantedSkillXp (skillId, amount), idempotencyKey ({playerId}:quest_complete:{questId}).
// No ingest or ILogger here (comments-only).
return SuccessFromEvent(deliveryEvent, reasonCode: null);
}
// Lost TryRecord race after apply — rollback this call's grants (mirror
// EncounterCompletionOperations when TryMarkCompleted returns false).
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
_ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants(
playerId,
appliedSkillXp,
perksUnlockedByThisCall,
skillProgressionStore,
perkStore);
ResyncPathAutoPerksAfterRaceRollback(
playerId,
appliedSkillXp,
skillProgressionStore,
levelCurve,
perkUnlockEngine);
if (deliveryStore.TryGet(normalizedPlayerId, normalizedQuestId, out var racedEvent))
{
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
}
private static void ResyncPathAutoPerksAfterRaceRollback(
string playerId,
IReadOnlyList appliedSkillXp,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine)
{
if (appliedSkillXp.Count == 0)
{
return;
}
var xpBySkill = skillProgressionStore.GetXpTotals(playerId);
foreach (var grant in appliedSkillXp)
{
xpBySkill.TryGetValue(grant.SkillId, out var totalXp);
var level = levelCurve.LevelFromTotalXp(totalXp);
_ = perkUnlockEngine.ReevaluateAfterLevelUp(playerId, grant.SkillId, level);
}
}
private static string MapInventoryReason(string? reasonCode) =>
reasonCode switch
{
PlayerInventoryReasonCodes.InventoryFull => RewardDeliveryReasonCodes.InventoryFull,
PlayerInventoryReasonCodes.InvalidItem => RewardDeliveryReasonCodes.InvalidItem,
PlayerInventoryReasonCodes.InvalidQuantity => RewardDeliveryReasonCodes.InvalidQuantity,
null => RewardDeliveryReasonCodes.InventoryFull,
_ => reasonCode,
};
private static void CompensatingRemoveItems(
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 RewardDeliveryResult Deny(string reasonCode) =>
new(
Success: false,
ReasonCode: reasonCode,
GrantedItems: EmptyItemGrants,
GrantedSkillXp: EmptySkillXpGrants,
DeliveryEvent: null,
DeliveredAt: null);
private static RewardDeliveryResult SuccessFromEvent(RewardDeliveryEvent deliveryEvent, string? reasonCode) =>
new(
Success: true,
ReasonCode: reasonCode,
GrantedItems: deliveryEvent.GrantedItems,
GrantedSkillXp: deliveryEvent.GrantedSkillXp,
DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt);
}