219 lines
9.0 KiB
C#
219 lines
9.0 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Applies encounter reward-table grants and marks completion idempotently (NEO-105).
|
|
/// Combat defeat wiring: <see cref="AbilityInput.AbilityCastApi"/> (NEO-106).
|
|
/// NEO-109 telemetry hook sites: <see cref="TryCompleteAndGrant"/> (<c>encounter_complete</c>, <c>reward_attribution</c>),
|
|
/// <see cref="Deny"/> (<c>encounter_complete_denied</c>).
|
|
/// </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,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore questProgressStore,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
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.
|
|
|
|
// --- 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,
|
|
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<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 — 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);
|
|
}
|
|
}
|