349 lines
14 KiB
C#
349 lines
14 KiB
C#
using NeonSprawl.Server.Game.Items;
|
|
using NeonSprawl.Server.Game.Mastery;
|
|
using NeonSprawl.Server.Game.Rewards;
|
|
using NeonSprawl.Server.Game.Skills;
|
|
|
|
namespace NeonSprawl.Server.Game.Quests;
|
|
|
|
/// <summary>
|
|
/// Server-authoritative quest accept, step advance, and complete with reason codes (NEO-117).
|
|
/// Objective wiring: E7M1-07 (NEO-118). HTTP: E7M1-08/09 (NEO-119/NEO-120).
|
|
/// Telemetry hook sites: E7M1-10 (NEO-121) — comment-only <c>quest_start</c>, <c>quest_step_complete</c>,
|
|
/// <c>quest_complete</c>, <c>quest_accept_denied</c> for future E9.M1.
|
|
/// </summary>
|
|
public static class QuestStateOperations
|
|
{
|
|
/// <summary>
|
|
/// Validates quest id and prerequisites, then activates at step 0 when not started.
|
|
/// </summary>
|
|
public static QuestStateOperationResult TryAccept(
|
|
string playerId,
|
|
string questId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
|
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
|
|
{
|
|
return DenyAccept(playerId, questId, QuestStateReasonCodes.UnknownQuest);
|
|
}
|
|
|
|
if (!progressStore.CanWritePlayer(playerId))
|
|
{
|
|
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.UnknownPlayer);
|
|
}
|
|
|
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
|
|
{
|
|
if (existing.Status == QuestProgressStatus.Completed)
|
|
{
|
|
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.AlreadyCompleted, existing);
|
|
}
|
|
|
|
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.AlreadyActive, existing);
|
|
}
|
|
|
|
foreach (var prerequisiteId in definition.PrerequisiteQuestIds)
|
|
{
|
|
if (!progressStore.TryGetProgress(playerId, prerequisiteId, out var prerequisite) ||
|
|
prerequisite.Status != QuestProgressStatus.Completed)
|
|
{
|
|
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.PrerequisiteIncomplete);
|
|
}
|
|
}
|
|
|
|
if (progressStore.TryActivate(playerId, normalizedQuestId, out var snapshot))
|
|
{
|
|
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_start` ---
|
|
// TODO(E9.M1): catalog emit — first activation only (deny paths excluded).
|
|
// Planned payload fields: playerId, questId, currentStepIndex (0), optional schemaVersion.
|
|
// No ingest or ILogger here (comments-only).
|
|
|
|
QuestObjectiveWiring.TryProcessInventoryHasItemForQuest(
|
|
playerId,
|
|
normalizedQuestId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
QuestObjectiveWiring.TryCompleteStepIfSatisfied(
|
|
playerId,
|
|
normalizedQuestId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
timeProvider);
|
|
|
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
|
|
{
|
|
return Success(refreshed);
|
|
}
|
|
|
|
return Success(snapshot);
|
|
}
|
|
|
|
return DenyActivateFailure(progressStore, playerId, normalizedQuestId, snapshot);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Advances an active quest to <paramref name="newStepIndex"/> when within catalog bounds.
|
|
/// </summary>
|
|
public static QuestStateOperationResult TryAdvanceStep(
|
|
string playerId,
|
|
string questId,
|
|
int newStepIndex,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
|
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
|
|
{
|
|
return Deny(QuestStateReasonCodes.UnknownQuest);
|
|
}
|
|
|
|
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var existing))
|
|
{
|
|
return Deny(QuestStateReasonCodes.NotActive);
|
|
}
|
|
|
|
if (existing.Status == QuestProgressStatus.Completed)
|
|
{
|
|
return Deny(QuestStateReasonCodes.AlreadyCompleted, existing);
|
|
}
|
|
|
|
if (existing.Status != QuestProgressStatus.Active)
|
|
{
|
|
return Deny(QuestStateReasonCodes.NotActive);
|
|
}
|
|
|
|
if (newStepIndex <= existing.CurrentStepIndex || newStepIndex >= definition.Steps.Count)
|
|
{
|
|
return Deny(QuestStateReasonCodes.InvalidStepIndex, existing);
|
|
}
|
|
|
|
if (progressStore.TryAdvanceStep(playerId, normalizedQuestId, newStepIndex, out var snapshot))
|
|
{
|
|
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_step_complete` ---
|
|
// TODO(E9.M1): catalog emit — once per committed step advance (chain quests may emit multiple per wiring pass).
|
|
// Planned payload fields: playerId, questId, completedStepIndex (prior index), newStepIndex;
|
|
// optional step id from catalog at E9.M1 wiring time. No ingest or ILogger here (comments-only).
|
|
|
|
QuestObjectiveWiring.TryProcessInventoryHasItemForQuest(
|
|
playerId,
|
|
normalizedQuestId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
QuestObjectiveWiring.TryCompleteStepIfSatisfied(
|
|
playerId,
|
|
normalizedQuestId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
timeProvider);
|
|
|
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var refreshed))
|
|
{
|
|
return Success(refreshed);
|
|
}
|
|
|
|
return Success(snapshot);
|
|
}
|
|
|
|
return DenyAdvanceFailure(progressStore, playerId, normalizedQuestId, snapshot, existing);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delivers <see cref="QuestDefRow.CompletionRewardBundle"/> then marks complete (NEO-128).
|
|
/// Idempotent: replays return success with the existing snapshot without re-delivering.
|
|
/// </summary>
|
|
public static QuestStateOperationResult TryMarkComplete(
|
|
string playerId,
|
|
string questId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
IPlayerInventoryStore inventoryStore,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
if (!questRegistry.TryNormalizeKnown(questId, out var normalizedQuestId) ||
|
|
!questRegistry.TryGetDefinition(normalizedQuestId, out var definition))
|
|
{
|
|
return Deny(QuestStateReasonCodes.UnknownQuest);
|
|
}
|
|
|
|
if (!progressStore.TryGetProgress(playerId, normalizedQuestId, out var progress))
|
|
{
|
|
return Deny(QuestStateReasonCodes.NotActive);
|
|
}
|
|
|
|
if (progress.Status == QuestProgressStatus.Completed)
|
|
{
|
|
return Success(progress);
|
|
}
|
|
|
|
if (progress.Status != QuestProgressStatus.Active)
|
|
{
|
|
return Deny(QuestStateReasonCodes.NotActive);
|
|
}
|
|
|
|
var delivery = RewardRouterOperations.TryDeliverQuestCompletion(
|
|
playerId,
|
|
normalizedQuestId,
|
|
definition.CompletionRewardBundle,
|
|
itemRegistry,
|
|
inventoryStore,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
timeProvider);
|
|
|
|
if (!delivery.Success)
|
|
{
|
|
return Deny(delivery.ReasonCode ?? RewardDeliveryReasonCodes.InvalidQuestId, progress);
|
|
}
|
|
|
|
if (progressStore.TryMarkComplete(playerId, normalizedQuestId, timeProvider.GetUtcNow(), out var snapshot))
|
|
{
|
|
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_complete` ---
|
|
// TODO(E9.M1): catalog emit — first-time completion only; idempotent replay returns above without hook.
|
|
// Planned payload fields: playerId, questId, completedAt; optional total step count.
|
|
// No ingest or ILogger here (comments-only).
|
|
|
|
return Success(snapshot);
|
|
}
|
|
|
|
if (snapshot?.Status == QuestProgressStatus.Completed)
|
|
{
|
|
return Success(snapshot);
|
|
}
|
|
|
|
if (progressStore.TryGetProgress(playerId, normalizedQuestId, out var afterAttempt) &&
|
|
afterAttempt.Status == QuestProgressStatus.Completed)
|
|
{
|
|
return Success(afterAttempt);
|
|
}
|
|
|
|
if (!progressStore.CanWritePlayer(playerId))
|
|
{
|
|
return Deny(QuestStateReasonCodes.UnknownPlayer);
|
|
}
|
|
|
|
return DenyFromProgressSnapshot(snapshot, QuestStateReasonCodes.NotActive)
|
|
?? Deny(QuestStateReasonCodes.UnknownPlayer, progress);
|
|
}
|
|
|
|
private static QuestStateOperationResult DenyActivateFailure(
|
|
IPlayerQuestStateStore progressStore,
|
|
string playerId,
|
|
string questId,
|
|
QuestStepState attemptSnapshot) =>
|
|
DenyFromProgressSnapshot(attemptSnapshot, QuestStateReasonCodes.AlreadyActive, playerId, questId)
|
|
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
|
|
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.AlreadyActive, playerId, questId)
|
|
: null)
|
|
?? DenyAccept(playerId, questId, QuestStateReasonCodes.UnknownPlayer);
|
|
|
|
private static QuestStateOperationResult DenyAdvanceFailure(
|
|
IPlayerQuestStateStore progressStore,
|
|
string playerId,
|
|
string questId,
|
|
QuestStepState attemptSnapshot,
|
|
QuestStepState priorRead) =>
|
|
DenyFromProgressSnapshot(attemptSnapshot, QuestStateReasonCodes.InvalidStepIndex)
|
|
?? (progressStore.TryGetProgress(playerId, questId, out var reread)
|
|
? DenyFromProgressSnapshot(reread, QuestStateReasonCodes.InvalidStepIndex)
|
|
: null)
|
|
?? Deny(QuestStateReasonCodes.UnknownPlayer, priorRead);
|
|
|
|
private static QuestStateOperationResult? DenyFromProgressSnapshot(
|
|
QuestStepState? snapshot,
|
|
string activeOrIntermediateReasonCode,
|
|
string? acceptDenyPlayerId = null,
|
|
string? acceptDenyQuestId = null)
|
|
{
|
|
if (snapshot is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (snapshot.Status == QuestProgressStatus.Completed)
|
|
{
|
|
return acceptDenyPlayerId is not null
|
|
? DenyAccept(acceptDenyPlayerId, acceptDenyQuestId ?? string.Empty, QuestStateReasonCodes.AlreadyCompleted, snapshot)
|
|
: Deny(QuestStateReasonCodes.AlreadyCompleted, snapshot);
|
|
}
|
|
|
|
if (snapshot.Status == QuestProgressStatus.Active)
|
|
{
|
|
return acceptDenyPlayerId is not null
|
|
? DenyAccept(acceptDenyPlayerId, acceptDenyQuestId ?? string.Empty, activeOrIntermediateReasonCode, snapshot)
|
|
: Deny(activeOrIntermediateReasonCode, snapshot);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static QuestStateOperationResult Success(QuestStepState snapshot) =>
|
|
new(Success: true, ReasonCode: null, Snapshot: snapshot);
|
|
|
|
private static QuestStateOperationResult DenyAccept(
|
|
string playerId,
|
|
string questId,
|
|
string reasonCode,
|
|
QuestStepState? snapshot = null)
|
|
{
|
|
// --- Telemetry hook site (NEO-121): future E9.M1 catalog event `quest_accept_denied` ---
|
|
// TODO(E9.M1): catalog emit — reasonCode from QuestStateReasonCodes (unknown_quest, prerequisite_incomplete, etc.).
|
|
// Planned payload fields: playerId, questId, reasonCode (caller context threaded for E9.M1 wiring).
|
|
// No ingest or ILogger here (comments-only).
|
|
|
|
return Deny(reasonCode, snapshot);
|
|
}
|
|
|
|
private static QuestStateOperationResult Deny(string reasonCode, QuestStepState? snapshot = null) =>
|
|
new(Success: false, ReasonCode: reasonCode, Snapshot: snapshot);
|
|
}
|