neon-sprawl/server/NeonSprawl.Server/Game/Quests/QuestStateOperations.cs

361 lines
14 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.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,
IFactionStandingStore standingStore,
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);
}
}
var gate = FactionGateOperations.TryEvaluate(
playerId,
definition.FactionGateRules,
standingStore);
if (!gate.Success)
{
// Quest-level deny always surfaces faction_gate_blocked (ops may carry unknown_faction for telemetry).
return DenyAccept(playerId, normalizedQuestId, QuestStateReasonCodes.FactionGateBlocked);
}
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);
}