211 lines
7.7 KiB
C#
211 lines
7.7 KiB
C#
using NeonSprawl.Server.Game.Factions;
|
|
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.Mastery;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
using NeonSprawl.Server.Game.Skills;
|
|
|
|
namespace NeonSprawl.Server.Game.Contracts;
|
|
|
|
/// <summary>
|
|
/// Server-authoritative contract completion on encounter clear (NEO-149).
|
|
/// HTTP: E7M4-08 (NEO-151). Telemetry hook sites: E7M4-09 (NEO-152).
|
|
/// </summary>
|
|
public static class ContractCompletionOperations
|
|
{
|
|
/// <summary>
|
|
/// When the player has an active contract bound to <paramref name="encounterId"/>, delivers the template
|
|
/// completion bundle then marks the instance complete and appends an outcome audit row.
|
|
/// </summary>
|
|
public static ContractCompletionOperationResult TryCompleteOnEncounterClear(
|
|
string playerId,
|
|
string encounterId,
|
|
IContractInstanceStore instanceStore,
|
|
IContractOutcomeStore outcomeStore,
|
|
IContractTemplateRegistry templateRegistry,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
IPlayerInventoryStore inventoryStore,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
var normalizedPlayerId = ContractInstanceIds.NormalizePlayerId(playerId);
|
|
var normalizedEncounterId = NormalizeEncounterId(encounterId);
|
|
if (normalizedPlayerId.Length == 0 || normalizedEncounterId.Length == 0)
|
|
{
|
|
return Deny(ContractCompletionReasonCodes.InvalidIds);
|
|
}
|
|
|
|
if (!instanceStore.TryGetActiveForPlayer(normalizedPlayerId, out var activeSnapshot))
|
|
{
|
|
return NoOp(ContractCompletionReasonCodes.NoActiveContract);
|
|
}
|
|
|
|
if (!templateRegistry.TryGetDefinition(activeSnapshot.TemplateId, out var template))
|
|
{
|
|
return Deny(ContractCompletionReasonCodes.UnknownTemplate, activeSnapshot);
|
|
}
|
|
|
|
if (!EncounterMatchesTemplate(normalizedEncounterId, template.EncounterTemplateId))
|
|
{
|
|
return NoOp(ContractCompletionReasonCodes.EncounterMismatch, activeSnapshot);
|
|
}
|
|
|
|
if (activeSnapshot.Status == ContractInstanceStatus.Completed)
|
|
{
|
|
return Success(activeSnapshot, TryGetExistingOutcome(outcomeStore, activeSnapshot));
|
|
}
|
|
|
|
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
|
|
{
|
|
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
|
|
}
|
|
|
|
var delivery = RewardRouterOperations.TryDeliverContractCompletion(
|
|
normalizedPlayerId,
|
|
activeSnapshot.ContractInstanceId,
|
|
template.CompletionRewardBundle,
|
|
itemRegistry,
|
|
inventoryStore,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
|
|
if (!delivery.Success)
|
|
{
|
|
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidContractInstanceId, activeSnapshot);
|
|
}
|
|
|
|
var completedAt = timeProvider.GetUtcNow();
|
|
if (instanceStore.TryMarkComplete(
|
|
normalizedPlayerId,
|
|
activeSnapshot.ContractInstanceId,
|
|
completedAt,
|
|
out var completedSnapshot))
|
|
{
|
|
var outcome = AppendOutcome(
|
|
outcomeStore,
|
|
completedSnapshot,
|
|
delivery,
|
|
completedAt);
|
|
// --- Telemetry hook site (NEO-152): future E9.M1 catalog event `contract_complete` ---
|
|
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns below without hook.
|
|
// Planned payload: playerId, contractInstanceId, templateId, encounterId, completedAt, idempotencyKey.
|
|
return Success(completedSnapshot, outcome);
|
|
}
|
|
|
|
if (completedSnapshot?.Status == ContractInstanceStatus.Completed)
|
|
{
|
|
return Success(completedSnapshot, TryGetExistingOutcome(outcomeStore, completedSnapshot));
|
|
}
|
|
|
|
if (instanceStore.TryGet(
|
|
normalizedPlayerId,
|
|
activeSnapshot.ContractInstanceId,
|
|
out var afterAttempt) &&
|
|
afterAttempt.Status == ContractInstanceStatus.Completed)
|
|
{
|
|
return Success(afterAttempt, TryGetExistingOutcome(outcomeStore, afterAttempt));
|
|
}
|
|
|
|
if (!instanceStore.CanWritePlayer(normalizedPlayerId))
|
|
{
|
|
return Deny(ContractCompletionReasonCodes.PlayerNotWritable, activeSnapshot);
|
|
}
|
|
|
|
if (instanceStore.TryGet(
|
|
normalizedPlayerId,
|
|
activeSnapshot.ContractInstanceId,
|
|
out var currentAfterFailedMark))
|
|
{
|
|
return Deny(ContractInstanceReasonCodes.InstanceNotActive, currentAfterFailedMark);
|
|
}
|
|
|
|
return Deny(ContractInstanceReasonCodes.InstanceNotFound, activeSnapshot);
|
|
}
|
|
|
|
private static ContractOutcomeRow? AppendOutcome(
|
|
IContractOutcomeStore outcomeStore,
|
|
ContractInstanceState snapshot,
|
|
RewardDeliveryResult delivery,
|
|
DateTimeOffset recordedAt)
|
|
{
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
|
|
snapshot.PlayerId,
|
|
snapshot.ContractInstanceId);
|
|
if (idempotencyKey.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var row = new ContractOutcomeRow(
|
|
MakeOutcomeRowId(idempotencyKey),
|
|
snapshot.ContractInstanceId,
|
|
snapshot.PlayerId,
|
|
idempotencyKey,
|
|
delivery.GrantedItems,
|
|
delivery.GrantedSkillXp,
|
|
delivery.GrantedReputation,
|
|
recordedAt);
|
|
|
|
if (outcomeStore.TryAppend(row))
|
|
{
|
|
return row;
|
|
}
|
|
|
|
return outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
|
|
? existing
|
|
: null;
|
|
}
|
|
|
|
private static ContractOutcomeRow? TryGetExistingOutcome(
|
|
IContractOutcomeStore outcomeStore,
|
|
ContractInstanceState snapshot)
|
|
{
|
|
var idempotencyKey = ContractOutcomeIds.MakeIdempotencyKey(
|
|
snapshot.PlayerId,
|
|
snapshot.ContractInstanceId);
|
|
return idempotencyKey.Length > 0 &&
|
|
outcomeStore.TryGetByIdempotencyKey(idempotencyKey, out var existing)
|
|
? existing
|
|
: null;
|
|
}
|
|
|
|
private static string MakeOutcomeRowId(string idempotencyKey) => $"{idempotencyKey}:outcome";
|
|
|
|
private static bool EncounterMatchesTemplate(string normalizedEncounterId, string templateEncounterId) =>
|
|
string.Equals(
|
|
normalizedEncounterId,
|
|
NormalizeEncounterId(templateEncounterId),
|
|
StringComparison.Ordinal);
|
|
|
|
private static string NormalizeEncounterId(string? encounterId) =>
|
|
encounterId?.Trim() ?? string.Empty;
|
|
|
|
private static ContractCompletionOperationResult NoOp(
|
|
string reasonCode,
|
|
ContractInstanceState? snapshot = null) =>
|
|
new(true, reasonCode, snapshot, null);
|
|
|
|
private static ContractCompletionOperationResult Success(
|
|
ContractInstanceState snapshot,
|
|
ContractOutcomeRow? outcome) =>
|
|
new(true, null, snapshot, outcome);
|
|
|
|
private static ContractCompletionOperationResult Deny(
|
|
string reasonCode,
|
|
ContractInstanceState? snapshot = null) =>
|
|
new(false, reasonCode, snapshot, null);
|
|
}
|