629 lines
21 KiB
C#
629 lines
21 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>
|
|
/// NEO-118: best-effort quest objective evaluation on gather/craft/encounter success and
|
|
/// <see cref="QuestObjectiveKinds.InventoryHasItem"/> snapshot passes (accept / step advance / inventory mutations).
|
|
/// </summary>
|
|
public static class QuestObjectiveWiring
|
|
{
|
|
private const int MaxStepCompletionIterations = 8;
|
|
|
|
/// <summary>Credits <see cref="QuestObjectiveKinds.GatherItem"/> objectives after a successful gather grant.</summary>
|
|
public static void TryProcessGatherSuccess(
|
|
string playerId,
|
|
string itemId,
|
|
int quantityGranted,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider,
|
|
ILogger? logger = null)
|
|
{
|
|
if (quantityGranted <= 0 || itemId.Trim().Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var normalizedItemId = itemId.Trim();
|
|
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
|
|
{
|
|
if (!TryGetActiveProgress(playerId, definition.Id, progressStore, out var progress))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ApplyGatherItemObjectives(
|
|
playerId,
|
|
definition,
|
|
progress,
|
|
normalizedItemId,
|
|
quantityGranted,
|
|
progressStore);
|
|
}
|
|
|
|
RefreshInventoryHasItemCountersForPlayer(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
|
|
TryCompleteAllActiveQuests(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogDebug(ex, "Quest gather wiring skipped for player {PlayerId}", playerId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Credits <see cref="QuestObjectiveKinds.CraftRecipe"/> objectives after a successful craft.</summary>
|
|
public static void TryProcessCraftSuccess(
|
|
string playerId,
|
|
string recipeId,
|
|
int batchQuantity,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider,
|
|
ILogger? logger = null)
|
|
{
|
|
if (batchQuantity <= 0 || recipeId.Trim().Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var normalizedRecipeId = recipeId.Trim();
|
|
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
|
|
{
|
|
if (!TryGetActiveProgress(playerId, definition.Id, progressStore, out var progress))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ApplyCraftRecipeObjectives(
|
|
playerId,
|
|
definition,
|
|
progress,
|
|
normalizedRecipeId,
|
|
batchQuantity,
|
|
progressStore);
|
|
}
|
|
|
|
RefreshInventoryHasItemCountersForPlayer(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
|
|
TryCompleteAllActiveQuests(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogDebug(ex, "Quest craft wiring skipped for player {PlayerId}", playerId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Credits <see cref="QuestObjectiveKinds.EncounterComplete"/> objectives after encounter completion.</summary>
|
|
public static void TryProcessEncounterCompleteSuccess(
|
|
string playerId,
|
|
string encounterId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider,
|
|
ILogger? logger = null)
|
|
{
|
|
if (encounterId.Trim().Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var normalizedEncounterId = encounterId.Trim();
|
|
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
|
|
{
|
|
if (!TryGetActiveProgress(playerId, definition.Id, progressStore, out var progress))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ApplyEncounterCompleteObjectives(
|
|
playerId,
|
|
definition,
|
|
progress,
|
|
normalizedEncounterId,
|
|
progressStore);
|
|
}
|
|
|
|
RefreshInventoryHasItemCountersForPlayer(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
|
|
TryCompleteAllActiveQuests(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogDebug(ex, "Quest encounter wiring skipped for player {PlayerId}", playerId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> counters for every active quest.</summary>
|
|
public static void RefreshInventoryHasItemCountersForPlayer(
|
|
string playerId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry)
|
|
{
|
|
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
|
|
{
|
|
TryProcessInventoryHasItemForQuest(
|
|
playerId,
|
|
definition.Id,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET polling hook (NEO-119): refresh <see cref="QuestObjectiveKinds.InventoryHasItem"/> counters and
|
|
/// auto-advance/complete active quests before a progress snapshot is returned.
|
|
/// </summary>
|
|
public static void TryRefreshInventoryProgressForPlayer(
|
|
string playerId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider,
|
|
ILogger? logger = null)
|
|
{
|
|
try
|
|
{
|
|
RefreshInventoryHasItemCountersForPlayer(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry);
|
|
|
|
TryCompleteAllActiveQuests(
|
|
playerId,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger?.LogDebug(ex, "Quest progress GET refresh skipped for player {PlayerId}", playerId);
|
|
}
|
|
}
|
|
|
|
/// <summary>Re-evaluates <see cref="QuestObjectiveKinds.InventoryHasItem"/> on the active step for one quest.</summary>
|
|
public static void TryProcessInventoryHasItemForQuest(
|
|
string playerId,
|
|
string questId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry)
|
|
{
|
|
if (!TryGetActiveProgress(playerId, questId, progressStore, out var progress) ||
|
|
!questRegistry.TryGetDefinition(questId, out var definition) ||
|
|
!inventoryStore.TryGetSnapshot(playerId, out var inventory))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var stepIndex = progress.CurrentStepIndex;
|
|
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var objective in definition.Steps[stepIndex].Objectives)
|
|
{
|
|
if (!string.Equals(objective.Kind, QuestObjectiveKinds.InventoryHasItem, StringComparison.Ordinal) ||
|
|
objective.ItemId is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var required = GetRequiredQuantity(objective);
|
|
var held = PlayerInventoryOperations.GetHeldQuantity(inventory, objective.ItemId, itemRegistry);
|
|
var newCount = Math.Min(held, required);
|
|
var current = GetCounter(progress, objective.Id);
|
|
if (newCount == current)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
progressStore.TryUpdateObjectiveCounter(playerId, questId, objective.Id, newCount, out _);
|
|
}
|
|
}
|
|
|
|
/// <summary>Advances or completes the quest when the active step's objectives are satisfied.</summary>
|
|
public static void TryCompleteStepIfSatisfied(
|
|
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,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
for (var iteration = 0; iteration < MaxStepCompletionIterations; iteration++)
|
|
{
|
|
if (!TryGetActiveProgress(playerId, questId, progressStore, out var progress) ||
|
|
!questRegistry.TryGetDefinition(questId, out var definition))
|
|
{
|
|
return;
|
|
}
|
|
|
|
var stepIndex = progress.CurrentStepIndex;
|
|
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
inventoryStore.TryGetSnapshot(playerId, out var inventory);
|
|
var step = definition.Steps[stepIndex];
|
|
if (!IsStepSatisfied(step, progress.ObjectiveCounters, inventory, itemRegistry))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (stepIndex >= definition.Steps.Count - 1)
|
|
{
|
|
QuestStateOperations.TryMarkComplete(
|
|
playerId,
|
|
questId,
|
|
questRegistry,
|
|
progressStore,
|
|
itemRegistry,
|
|
inventoryStore,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
return;
|
|
}
|
|
|
|
var advance = QuestStateOperations.TryAdvanceStep(
|
|
playerId,
|
|
questId,
|
|
stepIndex + 1,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
|
|
if (!advance.Success)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void TryCompleteAllActiveQuests(
|
|
string playerId,
|
|
IQuestDefinitionRegistry questRegistry,
|
|
IPlayerQuestStateStore progressStore,
|
|
IPlayerInventoryStore inventoryStore,
|
|
IItemDefinitionRegistry itemRegistry,
|
|
ISkillDefinitionRegistry skillRegistry,
|
|
IPlayerSkillProgressionStore skillProgressionStore,
|
|
ISkillLevelCurve levelCurve,
|
|
PerkUnlockEngine perkUnlockEngine,
|
|
IPlayerPerkStateStore perkStore,
|
|
IRewardDeliveryStore deliveryStore,
|
|
IFactionStandingStore standingStore,
|
|
IReputationDeltaStore auditStore,
|
|
TimeProvider timeProvider)
|
|
{
|
|
foreach (var definition in questRegistry.GetDefinitionsInIdOrder())
|
|
{
|
|
if (TryGetActiveProgress(playerId, definition.Id, progressStore, out _))
|
|
{
|
|
TryCompleteStepIfSatisfied(
|
|
playerId,
|
|
definition.Id,
|
|
questRegistry,
|
|
progressStore,
|
|
inventoryStore,
|
|
itemRegistry,
|
|
skillRegistry,
|
|
skillProgressionStore,
|
|
levelCurve,
|
|
perkUnlockEngine,
|
|
perkStore,
|
|
deliveryStore,
|
|
standingStore,
|
|
auditStore,
|
|
timeProvider);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void ApplyGatherItemObjectives(
|
|
string playerId,
|
|
QuestDefRow definition,
|
|
QuestStepState progress,
|
|
string itemId,
|
|
int quantityGranted,
|
|
IPlayerQuestStateStore progressStore)
|
|
{
|
|
var stepIndex = progress.CurrentStepIndex;
|
|
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var objective in definition.Steps[stepIndex].Objectives)
|
|
{
|
|
if (!string.Equals(objective.Kind, QuestObjectiveKinds.GatherItem, StringComparison.Ordinal) ||
|
|
objective.ItemId is null ||
|
|
!string.Equals(objective.ItemId, itemId, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var required = GetRequiredQuantity(objective);
|
|
var current = GetCounter(progress, objective.Id);
|
|
var newCount = Math.Min(current + quantityGranted, required);
|
|
if (newCount == current)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
progressStore.TryUpdateObjectiveCounter(playerId, definition.Id, objective.Id, newCount, out _);
|
|
progressStore.TryGetProgress(playerId, definition.Id, out progress);
|
|
}
|
|
}
|
|
|
|
private static void ApplyCraftRecipeObjectives(
|
|
string playerId,
|
|
QuestDefRow definition,
|
|
QuestStepState progress,
|
|
string recipeId,
|
|
int batchQuantity,
|
|
IPlayerQuestStateStore progressStore)
|
|
{
|
|
var stepIndex = progress.CurrentStepIndex;
|
|
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var objective in definition.Steps[stepIndex].Objectives)
|
|
{
|
|
if (!string.Equals(objective.Kind, QuestObjectiveKinds.CraftRecipe, StringComparison.Ordinal) ||
|
|
objective.RecipeId is null ||
|
|
!string.Equals(objective.RecipeId, recipeId, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var required = GetRequiredQuantity(objective);
|
|
var current = GetCounter(progress, objective.Id);
|
|
var newCount = Math.Min(current + batchQuantity, required);
|
|
if (newCount == current)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
progressStore.TryUpdateObjectiveCounter(playerId, definition.Id, objective.Id, newCount, out _);
|
|
progressStore.TryGetProgress(playerId, definition.Id, out progress);
|
|
}
|
|
}
|
|
|
|
private static void ApplyEncounterCompleteObjectives(
|
|
string playerId,
|
|
QuestDefRow definition,
|
|
QuestStepState progress,
|
|
string encounterId,
|
|
IPlayerQuestStateStore progressStore)
|
|
{
|
|
var stepIndex = progress.CurrentStepIndex;
|
|
if (stepIndex < 0 || stepIndex >= definition.Steps.Count)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var objective in definition.Steps[stepIndex].Objectives)
|
|
{
|
|
if (!string.Equals(objective.Kind, QuestObjectiveKinds.EncounterComplete, StringComparison.Ordinal) ||
|
|
objective.EncounterId is null ||
|
|
!string.Equals(objective.EncounterId, encounterId, StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (GetCounter(progress, objective.Id) >= 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
progressStore.TryUpdateObjectiveCounter(playerId, definition.Id, objective.Id, 1, out _);
|
|
progressStore.TryGetProgress(playerId, definition.Id, out progress);
|
|
}
|
|
}
|
|
|
|
private static bool IsStepSatisfied(
|
|
QuestStepDefRow step,
|
|
IReadOnlyDictionary<string, int> counters,
|
|
PlayerInventorySnapshot inventory,
|
|
IItemDefinitionRegistry itemRegistry)
|
|
{
|
|
foreach (var objective in step.Objectives)
|
|
{
|
|
var required = GetRequiredQuantity(objective);
|
|
if (string.Equals(objective.Kind, QuestObjectiveKinds.InventoryHasItem, StringComparison.Ordinal))
|
|
{
|
|
if (objective.ItemId is null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var held = PlayerInventoryOperations.GetHeldQuantity(inventory, objective.ItemId, itemRegistry);
|
|
if (held < required)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (GetCounter(counters, objective.Id) < required)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return step.Objectives.Count > 0;
|
|
}
|
|
|
|
private static bool TryGetActiveProgress(
|
|
string playerId,
|
|
string questId,
|
|
IPlayerQuestStateStore progressStore,
|
|
out QuestStepState progress) =>
|
|
progressStore.TryGetProgress(playerId, questId, out progress!) &&
|
|
progress.Status == QuestProgressStatus.Active;
|
|
|
|
private static int GetRequiredQuantity(QuestObjectiveDefRow objective) =>
|
|
objective.Quantity is > 0 ? objective.Quantity.Value : 1;
|
|
|
|
private static int GetCounter(QuestStepState progress, string objectiveId) =>
|
|
GetCounter(progress.ObjectiveCounters, objectiveId);
|
|
|
|
private static int GetCounter(IReadOnlyDictionary<string, int> counters, string objectiveId) =>
|
|
counters.TryGetValue(objectiveId, out var count) ? count : 0;
|
|
}
|