541 lines
21 KiB
C#
541 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);
|
|
/// shared bundle rollback <c>reward_anomaly</c> (NEO-152).
|
|
/// </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 or contract completion delivery (TryRecord success).
|
|
// Not on TryGet idempotent replay, deny paths, or TryRecord race-loser rollback paths.
|
|
// Planned payload fields: playerId, sourceKind, sourceId, 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);
|
|
}
|
|
|
|
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `reward_anomaly` (stub) ---
|
|
// TODO(E9.M1): catalog emit — partial grant apply then compensating revert (skill XP, reputation, TryRecord race).
|
|
// E9.M1 filters by DeliveryTarget.SourceKind (contract_completion vs quest_completion).
|
|
// Planned payload fields: playerId, sourceKind, sourceId, reasonCode, partialAppliedGrants summary.
|
|
// Not on early denies before any grant apply or idempotent TryGet replay. No ingest or ILogger (comments-only).
|
|
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);
|
|
}
|