neon-sprawl/server/NeonSprawl.Server/Game/Rewards/RewardRouterOperations.cs

535 lines
21 KiB
C#

using NeonSprawl.Server.Game.Contracts;
using NeonSprawl.Server.Game.Encounters;
using NeonSprawl.Server.Game.Factions;
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;
/// <summary>
/// Applies quest and contract <see cref="QuestRewardBundleRow"/> completion grants idempotently (NEO-127, NEO-148).
/// Quest-state wiring: <see cref="QuestStateOperations"/> (NEO-128).
/// Reputation grants: <see cref="ReputationOperations"/> (NEO-138).
/// E9.M1 telemetry hook sites: <see cref="TryDeliverQuestCompletion"/> (NEO-130).
/// </summary>
public static class RewardRouterOperations
{
private static readonly RewardItemGrantApplied[] EmptyItemGrants = [];
private static readonly RewardSkillXpGrantApplied[] EmptySkillXpGrants = [];
private static readonly RewardReputationGrantApplied[] EmptyReputationGrants = [];
private static readonly IReadOnlyList<RewardGrantRow> EmptyBundleItemGrantRows = [];
private static readonly IReadOnlyList<QuestSkillXpGrantRow> EmptyBundleSkillXpGrantRows = [];
private static readonly IReadOnlyList<ReputationGrantRow> EmptyBundleReputationGrantRows = [];
/// <summary>
/// Applies <paramref name="bundle"/> item, skill XP, and reputation rows for a quest completion and records
/// <see cref="RewardDeliveryEvent"/> when grants succeed.
/// </summary>
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,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
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);
}
return TryDeliverBundle(
playerId,
bundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider,
new DeliveryTarget(
normalizedPlayerId,
RewardDeliverySourceKinds.QuestCompletion,
normalizedQuestId,
RewardDeliveryIds.MakeIdempotencyKey(normalizedPlayerId, normalizedQuestId),
ReputationDeltaSourceKinds.QuestCompletion,
normalizedQuestId,
normalizedQuestId,
string.Empty));
}
/// <summary>
/// Applies <paramref name="bundle"/> item, skill XP, and reputation rows for a contract completion and records
/// <see cref="RewardDeliveryEvent"/> when grants succeed (NEO-148).
/// </summary>
public static RewardDeliveryResult TryDeliverContractCompletion(
string playerId,
string contractInstanceId,
QuestRewardBundleRow? bundle,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
var normalizedPlayerId = RewardDeliveryIds.NormalizePlayerId(playerId);
if (normalizedPlayerId.Length == 0)
{
return Deny(RewardDeliveryReasonCodes.InvalidPlayerId);
}
var normalizedInstanceId = RewardDeliveryIds.NormalizeContractInstanceId(contractInstanceId);
if (normalizedInstanceId.Length == 0)
{
return Deny(RewardDeliveryReasonCodes.InvalidContractInstanceId);
}
return TryDeliverBundle(
playerId,
bundle,
itemRegistry,
inventoryStore,
skillRegistry,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
deliveryStore,
standingStore,
auditStore,
timeProvider,
new DeliveryTarget(
normalizedPlayerId,
RewardDeliverySourceKinds.ContractCompletion,
normalizedInstanceId,
ContractOutcomeIds.MakeIdempotencyKey(normalizedPlayerId, normalizedInstanceId),
ReputationDeltaSourceKinds.ContractCompletion,
normalizedInstanceId,
string.Empty,
normalizedInstanceId));
}
private static RewardDeliveryResult TryDeliverBundle(
string playerId,
QuestRewardBundleRow? bundle,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
ISkillDefinitionRegistry skillRegistry,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IRewardDeliveryStore deliveryStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider,
DeliveryTarget target)
{
if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var existingEvent))
{
return SuccessFromEvent(existingEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
var itemGrants = bundle?.ItemGrants ?? EmptyBundleItemGrantRows;
var skillGrants = bundle?.SkillXpGrants ?? EmptyBundleSkillXpGrantRows;
var reputationGrants = bundle?.ReputationGrants ?? EmptyBundleReputationGrantRows;
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<RewardItemGrantApplied>(itemGrants.Count);
foreach (var grant in itemGrants)
{
var addOutcome = PlayerInventoryOperations.TryAddStack(
playerId,
grant.ItemId,
grant.Quantity,
itemRegistry,
inventoryStore);
if (addOutcome.Kind == PlayerInventoryMutationKind.StoreMissing)
{
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<string>(StringComparer.Ordinal);
var appliedSkillXp = new List<RewardSkillXpGrantApplied>(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)
{
CompensatingRevertAll(
playerId,
target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
[],
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
return Deny(RewardDeliveryReasonCodes.ProgressionStoreMissing);
}
if (skillOutcome.Kind == SkillProgressionGrantApplyKind.Denied)
{
CompensatingRevertAll(
playerId,
target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
[],
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
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 appliedReputation = new List<RewardReputationGrantApplied>(reputationGrants.Count);
foreach (var grant in reputationGrants)
{
var deltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var repOutcome = ReputationOperations.TryApplyDelta(
playerId,
grant.FactionId,
grant.Amount,
deltaId,
target.ReputationSourceKind,
target.ReputationSourceId,
standingStore,
auditStore,
timeProvider);
if (!repOutcome.Success)
{
CompensatingRevertAll(
playerId,
target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
appliedReputation,
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
return Deny(MapReputationReason(repOutcome.ReasonCode));
}
var appliedAmount = repOutcome.NewStanding - repOutcome.PreviousStanding;
appliedReputation.Add(new RewardReputationGrantApplied(grant.FactionId, appliedAmount));
}
var deliveredAt = timeProvider.GetUtcNow();
var deliveryEvent = new RewardDeliveryEvent(
target.NormalizedPlayerId,
target.QuestId,
deliveredAt,
appliedItems,
appliedSkillXp,
appliedReputation,
target.IdempotencyKey,
target.SourceKind,
target.ContractInstanceId);
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), grantedReputation (factionId, amount), idempotencyKey.
// 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).
CompensatingRevertAll(
playerId,
target,
appliedItems,
appliedSkillXp,
perksUnlockedByThisCall,
appliedReputation,
itemRegistry,
inventoryStore,
skillProgressionStore,
levelCurve,
perkUnlockEngine,
perkStore,
standingStore,
auditStore,
timeProvider);
if (deliveryStore.TryGet(target.NormalizedPlayerId, target.SourceKind, target.SourceId, out var racedEvent))
{
return SuccessFromEvent(racedEvent, RewardDeliveryReasonCodes.AlreadyDelivered);
}
return Deny(RewardDeliveryReasonCodes.AlreadyDelivered);
}
private static void CompensatingRevertAll(
string playerId,
DeliveryTarget target,
IReadOnlyList<RewardItemGrantApplied> appliedItems,
IReadOnlyList<RewardSkillXpGrantApplied> appliedSkillXp,
HashSet<string> perksUnlockedByThisCall,
IReadOnlyList<RewardReputationGrantApplied> appliedReputation,
IItemDefinitionRegistry itemRegistry,
IPlayerInventoryStore inventoryStore,
IPlayerSkillProgressionStore skillProgressionStore,
ISkillLevelCurve levelCurve,
PerkUnlockEngine perkUnlockEngine,
IPlayerPerkStateStore perkStore,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
CompensatingRevertReputation(
playerId,
target,
appliedReputation,
standingStore,
auditStore,
timeProvider);
_ = SkillProgressionGrantOperations.TryRevertAppliedSkillGrants(
playerId,
appliedSkillXp,
perksUnlockedByThisCall,
skillProgressionStore,
perkStore);
CompensatingRemoveItems(playerId, appliedItems, itemRegistry, inventoryStore);
ResyncPathAutoPerksAfterRaceRollback(
playerId,
appliedSkillXp,
skillProgressionStore,
levelCurve,
perkUnlockEngine);
}
private static void CompensatingRevertReputation(
string playerId,
DeliveryTarget target,
IReadOnlyList<RewardReputationGrantApplied> grants,
IFactionStandingStore standingStore,
IReputationDeltaStore auditStore,
TimeProvider timeProvider)
{
for (var i = grants.Count - 1; i >= 0; i--)
{
var grant = grants[i];
if (grant.Amount == 0)
{
continue;
}
var forwardDeltaId = RewardDeliveryIds.MakeReputationDeltaId(target.IdempotencyKey, grant.FactionId);
var rollbackDeltaId = RewardDeliveryIds.MakeReputationRollbackDeltaId(forwardDeltaId);
_ = ReputationOperations.TryApplyDelta(
playerId,
grant.FactionId,
-grant.Amount,
rollbackDeltaId,
target.ReputationSourceKind,
target.ReputationSourceId,
standingStore,
auditStore,
timeProvider);
}
}
private static void ResyncPathAutoPerksAfterRaceRollback(
string playerId,
IReadOnlyList<RewardSkillXpGrantApplied> 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 string MapReputationReason(string? reasonCode) =>
reasonCode switch
{
FactionStandingReasonCodes.UnknownFaction => RewardDeliveryReasonCodes.UnknownFaction,
ReputationApplyReasonCodes.InvalidDelta => RewardDeliveryReasonCodes.InvalidDelta,
ReputationApplyReasonCodes.InvalidDeltaId => RewardDeliveryReasonCodes.InvalidDeltaId,
ReputationApplyReasonCodes.InvalidSource => RewardDeliveryReasonCodes.InvalidSource,
ReputationApplyReasonCodes.AuditAppendFailed => RewardDeliveryReasonCodes.AuditAppendFailed,
null => RewardDeliveryReasonCodes.UnknownFaction,
_ => reasonCode,
};
private static void CompensatingRemoveItems(
string playerId,
IReadOnlyList<RewardItemGrantApplied> 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,
GrantedReputation: EmptyReputationGrants,
DeliveryEvent: null,
DeliveredAt: null);
private static RewardDeliveryResult SuccessFromEvent(RewardDeliveryEvent deliveryEvent, string? reasonCode) =>
new(
Success: true,
ReasonCode: reasonCode,
GrantedItems: deliveryEvent.GrantedItems,
GrantedSkillXp: deliveryEvent.GrantedSkillXp,
GrantedReputation: deliveryEvent.GrantedReputation,
DeliveryEvent: deliveryEvent,
DeliveredAt: deliveryEvent.DeliveredAt);
private readonly record struct DeliveryTarget(
string NormalizedPlayerId,
string SourceKind,
string SourceId,
string IdempotencyKey,
string ReputationSourceKind,
string ReputationSourceId,
string QuestId,
string ContractInstanceId);
}