From 64f4085b9b0d77df30feb729d3f4c209e98a2dc6 Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 27 Oct 2026 13:15:05 -0500 Subject: [PATCH 1/5] Add player-specific collection nodes and quest collections --- src/Sanctuary.Game.Tests/QuestGoalTests.cs | 57 +++++++- src/Sanctuary.Game/Quests/GoalProgress.cs | 27 ++++ src/Sanctuary.Game/Quests/IQuestManager.cs | 2 + src/Sanctuary.Game/Quests/QuestManager.cs | 47 +++++-- .../CollectionNodePoolDefinition.cs | 1 + .../CollectionNodeTypeDefinition.cs | 2 + .../Definitions/QuestGoals/CollectGoal.cs | 19 ++- .../CommandPacketInteractRequestHandler.cs | 124 ++++++++++++------ 8 files changed, 215 insertions(+), 64 deletions(-) diff --git a/src/Sanctuary.Game.Tests/QuestGoalTests.cs b/src/Sanctuary.Game.Tests/QuestGoalTests.cs index f69d0e02..d36dc5a5 100644 --- a/src/Sanctuary.Game.Tests/QuestGoalTests.cs +++ b/src/Sanctuary.Game.Tests/QuestGoalTests.cs @@ -77,6 +77,49 @@ public void CollectGoal_CountsItsNodeTypeUntilTheRequiredCount() Assert.AreEqual(QuestGoalResult.Completed, goal.Progress(Quest, Gather("tin-ore"), progress)); } + [TestMethod] + public void CollectGoal_IgnoresSharedNodes() + { + var goal = new CollectGoal { CollectNodeType = "tin-ore", RequiredCount = 1 }; + var sharedNode = new CollectionNode(null!, + new CollectionNodeTypeDefinition { Key = "tin-ore", Name = "tin-ore" }, + new CollectionNodePoolDefinition { Key = "tin-ore", NodeType = "tin-ore" }, + new CollectionNodeSpawnDefinition { Id = 1 }); + + Assert.IsFalse(goal.CanGather(sharedNode, goal.CreateProgress())); + Assert.AreEqual(QuestGoalResult.None, goal.Progress(Quest, new NodeGatheredEvent(sharedNode), goal.CreateProgress())); + } + + [TestMethod] + public void CollectGoal_CountsEachPlayerSpecificNodeOnce() + { + var goal = new CollectGoal { CollectNodeType = "tin-ore", RequiredCount = 2 }; + var progress = goal.CreateProgress(); + + Assert.IsTrue(goal.CanGather(PlayerNode("tin-ore", 1), progress)); + Assert.AreEqual(QuestGoalResult.Progressed, goal.Progress(Quest, GatherPlayerNode("tin-ore", 1), progress)); + Assert.IsFalse(goal.CanGather(PlayerNode("tin-ore", 1), progress)); + Assert.AreEqual(QuestGoalResult.None, goal.Progress(Quest, GatherPlayerNode("tin-ore", 1), progress)); + Assert.AreEqual(QuestGoalResult.Completed, goal.Progress(Quest, GatherPlayerNode("tin-ore", 2), progress)); + } + + [TestMethod] + public void CollectGoal_SavesWhichPlayerSpecificNodesWereGathered() + { + var goal = new CollectGoal { CollectNodeType = "tin-ore", RequiredCount = 3 }; + var progress = goal.CreateProgress(); + goal.Progress(Quest, GatherPlayerNode("tin-ore", 7), progress); + goal.Progress(Quest, GatherPlayerNode("tin-ore", 9), progress); + + var loaded = goal.CreateProgress(); + foreach (var (targetId, count) in progress.Save()) + loaded.Load(targetId, count); + + Assert.AreEqual(2, loaded.Count); + Assert.IsFalse(goal.CanGather(PlayerNode("tin-ore", 7), loaded)); + Assert.IsTrue(goal.CanGather(PlayerNode("tin-ore", 8), loaded)); + } + [TestMethod] public void ReachGoal_CompletesOnlyInsideItsRadius() { @@ -132,7 +175,7 @@ public void Progress_RoundTripsThroughItsSavedRows() foreach (var (targetId, count) in collect.Save()) loadedCollect.Load(targetId, count); - Assert.AreEqual(1, collect.Save().Count()); + Assert.AreEqual(2, collect.Save().Count()); Assert.AreEqual(2, loadedCollect.Count); } @@ -175,8 +218,14 @@ public void IsWaitingOn_OnlyTheCurrentGoalsUntalkedNpcsThenTheTurnInNpc() private static PlayerMovedEvent Move(float x, float z, int zoneDefinitionId = ReachLocationGoal.FabledRealmsZoneDefinitionId) => new(new Vector4(x, 0f, z, 1f), zoneDefinitionId); - private static NodeGatheredEvent Gather(string nodeType) => new(new CollectionNode(null!, + private static CollectionNode PlayerNode(string nodeType, int spawnId) => new(null!, new CollectionNodeTypeDefinition { Key = nodeType, Name = nodeType }, - new CollectionNodePoolDefinition { Key = nodeType, NodeType = nodeType }, - new CollectionNodeSpawnDefinition())); + new CollectionNodePoolDefinition { Key = nodeType, NodeType = nodeType, PlayerSpecific = true }, + new CollectionNodeSpawnDefinition { Id = spawnId }); + + private static NodeGatheredEvent GatherPlayerNode(string nodeType, int spawnId) => new(PlayerNode(nodeType, spawnId)); + + private static int _nextSpawnId; + + private static NodeGatheredEvent Gather(string nodeType) => GatherPlayerNode(nodeType, ++_nextSpawnId); } diff --git a/src/Sanctuary.Game/Quests/GoalProgress.cs b/src/Sanctuary.Game/Quests/GoalProgress.cs index ed3562f9..23aac3cf 100644 --- a/src/Sanctuary.Game/Quests/GoalProgress.cs +++ b/src/Sanctuary.Game/Quests/GoalProgress.cs @@ -41,3 +41,30 @@ public override void Load(ulong targetId, int count) TryAdd((int)targetId); } } + +public sealed class CollectProgress : GoalProgress +{ + private readonly HashSet _gatheredSpawnIds = []; + + public bool HasGathered(int spawnId) => _gatheredSpawnIds.Contains(spawnId); + + public bool TryAdd(int spawnId) + { + if (!_gatheredSpawnIds.Add(spawnId)) + return false; + + Count++; + return true; + } + + public override IEnumerable<(ulong TargetId, int Count)> Save() + => _gatheredSpawnIds.Count > 0 ? _gatheredSpawnIds.Select(spawnId => ((ulong)spawnId, 1)) : base.Save(); + + public override void Load(ulong targetId, int count) + { + if (targetId != 0) + TryAdd((int)targetId); + else + base.Load(targetId, count); + } +} diff --git a/src/Sanctuary.Game/Quests/IQuestManager.cs b/src/Sanctuary.Game/Quests/IQuestManager.cs index 05672284..9decc91b 100644 --- a/src/Sanctuary.Game/Quests/IQuestManager.cs +++ b/src/Sanctuary.Game/Quests/IQuestManager.cs @@ -14,6 +14,8 @@ public interface IQuestManager void OnEvent(Player player, QuestEvent questEvent); + bool CanGatherQuestNode(Player player, CollectionNode node); + void AcceptQuest(Player player, int questId); bool CompleteQuest(Player player, int questId); diff --git a/src/Sanctuary.Game/Quests/QuestManager.cs b/src/Sanctuary.Game/Quests/QuestManager.cs index 85463f84..52c5b977 100644 --- a/src/Sanctuary.Game/Quests/QuestManager.cs +++ b/src/Sanctuary.Game/Quests/QuestManager.cs @@ -310,6 +310,8 @@ public void AcceptQuest(Player player, int questId) RefreshQuestNotifications(player, quest); + RefreshQuestNodes(player, quest); + player.SendTunneled(new CommandPacketQuestDialogComplete()); } @@ -428,6 +430,8 @@ private void RemoveQuest(Player player, QuestDefinition quest) player.SendTunneled(new QuestAbandonedPacket { QuestId = questId }); + RefreshQuestNodes(player, quest); + RefreshQuestNotifications(player, quest); RefreshObjectiveTarget(player); @@ -620,8 +624,34 @@ public void RefreshQuestNotification(Player player, ulong npcGuid) return (0, false); } + public bool CanGatherQuestNode(Player player, CollectionNode node) + => ActiveGoals(player).Any(active => active.Goal is CollectGoal collectGoal + && collectGoal.CanGather(node, active.Progress.OpenGoals[active.GoalIndex])); + + private void RefreshQuestNodes(Player player, QuestDefinition quest) + { + var nodeTypes = quest.Goals.OfType().Select(goal => goal.CollectNodeType).ToHashSet(); + if (nodeTypes.Count == 0) + return; + + foreach (var node in player.Zone.Npcs.OfType() + .Where(node => node.PoolDefinition.PlayerSpecific && nodeTypes.Contains(node.TypeDefinition.Key)).ToList()) + { + var hidden = !CanGatherQuestNode(player, node); + var shown = player.VisibleNpcs.ContainsKey(node.Guid); + + if (hidden && shown) + player.OnRemoveVisibleNpcGracefully(node, animate: true); + else if (!hidden && !shown && node.VisiblePlayers.ContainsKey(player.Guid)) + player.OnAddVisibleNpcs(node); + } + } + public bool IsHiddenByQuest(Player player, Npc npc) { + if (npc is CollectionNode { PoolDefinition.PlayerSpecific: true } questNode) + return !CanGatherQuestNode(player, questNode); + var quests = _resourceManager.Quests; if (!quests.ByStateNpc.TryGetValue(npc.DefinitionId, out var questIds)) @@ -800,6 +830,7 @@ private void CompleteGoal(Player player, QuestDefinition quest, QuestProgress pr RemoveHiddenNpcs(player, completedGoal); RefreshNpcEffects(player, completedGoal); + RefreshQuestNodes(player, quest); RefreshQuestNotifications(player, quest); if (isFinalGoal) @@ -1013,14 +1044,6 @@ private ulong ResolveGoalTargetGuid(Player player, QuestDefinition quest, int go { var goals = quest.Goals; - if (goalIndex >= 0 && goalIndex < goals.Count - && goals[goalIndex] is CollectGoal collectGoal) - { - var nearest = NearestCollectionNode(player, collectGoal.CollectNodeType); - if (nearest is not null) - return nearest.Guid; - } - if (goalIndex >= 0 && goalIndex < goals.Count && goals[goalIndex] is TalkToNpcGoal { IsCounted: true } countedGoal && player.Quests.TryGetInProgress(quest.QuestId, out var progress)) { @@ -1032,15 +1055,11 @@ private ulong ResolveGoalTargetGuid(Player player, QuestDefinition quest, int go return NearestNpcGuid(player, quest.GoalTargetNpcId(goalIndex)); } - private static Npc? NearestCollectionNode(Player player, string nodeType) - => string.IsNullOrEmpty(nodeType) - ? null - : NearestNpc(player, npc => npc is CollectionNode node && node.TypeDefinition.Key == nodeType); private void SendObjectiveForGoal(Player player, QuestDefinition quest, int goalIndex) { var goals = quest.Goals; - if (quest.HideObjectiveTarget) + if (quest.HideObjectiveTarget || (goalIndex >= 0 && goalIndex < goals.Count && goals[goalIndex] is CollectGoal)) { player.SendTunneled(new ObjectiveTargetUpdatePacket { Active = false }); return; @@ -1124,7 +1143,7 @@ private bool TryGetTrackableGoal(Player player, int questId, out QuestDefinition int currentGoalIndex = progress.FirstOpenGoalIndex; var goals = q.Goals; - if (q.HideObjectiveTarget) + if (q.HideObjectiveTarget || (currentGoalIndex >= 0 && currentGoalIndex < goals.Count && goals[currentGoalIndex] is CollectGoal)) return false; if (currentGoalIndex >= 0 && currentGoalIndex < goals.Count diff --git a/src/Sanctuary.Game/Resources/Definitions/CollectionNodePoolDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/CollectionNodePoolDefinition.cs index f7f70be8..a9b4edfe 100644 --- a/src/Sanctuary.Game/Resources/Definitions/CollectionNodePoolDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/CollectionNodePoolDefinition.cs @@ -13,6 +13,7 @@ public sealed class CollectionNodePoolDefinition public required string NodeType { get; set; } public int MaxActiveNodes { get; set; } public int RespawnSeconds { get; set; } = 30; + public bool PlayerSpecific { get; set; } public List DropTable { get; set; } = []; public bool HasDrop => DropTable.Count > 0; diff --git a/src/Sanctuary.Game/Resources/Definitions/CollectionNodeTypeDefinition.cs b/src/Sanctuary.Game/Resources/Definitions/CollectionNodeTypeDefinition.cs index 3b3e1699..f1cd1e65 100644 --- a/src/Sanctuary.Game/Resources/Definitions/CollectionNodeTypeDefinition.cs +++ b/src/Sanctuary.Game/Resources/Definitions/CollectionNodeTypeDefinition.cs @@ -12,4 +12,6 @@ public sealed class CollectionNodeTypeDefinition public byte CursorId { get; set; } = 18; public float PlacementYOffset { get; set; } public int SubTextNameId { get; set; } + public int CollectionId { get; set; } + public int CollectionEntryId { get; set; } } diff --git a/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs b/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs index 7b90f68d..a1a2f06d 100644 --- a/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs +++ b/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs @@ -1,4 +1,5 @@ -using Sanctuary.Game.Quests; +using Sanctuary.Game.Entities; +using Sanctuary.Game.Quests; namespace Sanctuary.Game.Resources.Definitions.QuestGoals; @@ -8,15 +9,27 @@ public sealed class CollectGoal : QuestGoal // instead of quests owning their own spawn points. public string CollectNodeType { get; set; } = string.Empty; + public override GoalProgress CreateProgress() => new CollectProgress(); + + public bool CanGather(CollectionNode node, GoalProgress progress) + => RequiredCount > 0 + && node.PoolDefinition.PlayerSpecific + && node.TypeDefinition.Key == CollectNodeType + && !(progress is CollectProgress collectProgress && collectProgress.HasGathered(node.SpawnDefinition.Id)); + public override bool Matches(QuestDefinition quest, QuestEvent questEvent) - => RequiredCount > 0 && questEvent is NodeGatheredEvent { Node: var node } && node.TypeDefinition.Key == CollectNodeType; + => RequiredCount > 0 + && questEvent is NodeGatheredEvent { Node: var node } + && node.PoolDefinition.PlayerSpecific + && node.TypeDefinition.Key == CollectNodeType; public override QuestGoalResult Progress(QuestDefinition quest, QuestEvent questEvent, GoalProgress progress) { if (!Matches(quest, questEvent)) return QuestGoalResult.None; - progress.Increment(); + if (progress is not CollectProgress collectProgress || !collectProgress.TryAdd(((NodeGatheredEvent)questEvent).Node.SpawnDefinition.Id)) + return QuestGoalResult.None; return progress.Count >= RequiredCount ? QuestGoalResult.Completed : QuestGoalResult.Progressed; } diff --git a/src/Sanctuary.Gateway/Handlers/BaseCommandPacket/CommandPacketInteractRequestHandler.cs b/src/Sanctuary.Gateway/Handlers/BaseCommandPacket/CommandPacketInteractRequestHandler.cs index 9d53dc80..0a827520 100644 --- a/src/Sanctuary.Gateway/Handlers/BaseCommandPacket/CommandPacketInteractRequestHandler.cs +++ b/src/Sanctuary.Gateway/Handlers/BaseCommandPacket/CommandPacketInteractRequestHandler.cs @@ -140,51 +140,11 @@ private static bool HandleCollectionCoin(GatewayConnection connection, Collectio try { - if (!_resourceManager.Collections.TryGetValue(coin.Definition.CollectionId, out var collection)) - return false; - - var entryIndex = collection.Entries.FindIndex(entry => entry.Id == coin.Definition.EntryId); - - if (entryIndex < 0) - return false; - - var ownedItemDefinitionIds = connection.Player.Items - .Select(item => item.Definition) - .ToHashSet(); - var collectedEntryIds = connection.Player.CollectedCollectionEntryIds.ToHashSet(); - var collectionWasStarted = collection.IsStarted(ownedItemDefinitionIds, collectedEntryIds); - var characterId = GuidHelper.GetPlayerId(connection.Player.Guid); - - using var dbContext = _dbContextFactory.CreateDbContext(); - dbContext.CollectionEntries.Add(new DbCollectionEntry + return TryAddCollectionEntry(connection, coin.Definition.CollectionId, coin.Definition.EntryId, () => { - CharacterId = characterId, - CollectionId = coin.Definition.CollectionId, - EntryId = coin.Definition.EntryId + connection.Player.OnRemoveVisibleNpcGracefully(coin, animate: true); + coin.OnRemoveVisiblePlayers(connection.Player); }); - - if (dbContext.SaveChanges() <= 0) - return false; - - connection.Player.CollectedCollectionEntryIds.TryAdd(coin.Definition.EntryId); - collectedEntryIds.Add(coin.Definition.EntryId); - var collectionWillBeComplete = collection.IsComplete(ownedItemDefinitionIds, collectedEntryIds); - - connection.Player.OnRemoveVisibleNpcGracefully(coin, animate: true); - coin.OnRemoveVisiblePlayers(connection.Player); - - if (collectionWasStarted && collectionWillBeComplete) - { - connection.SendTunneled(new ClientUpdatePacketCollectionRemoveEntry - { - CollectionId = collection.Id, - EntryId = coin.Definition.EntryId - }); - } - - SendCollectionEntryUpdate( - connection, collection, collection.Entries[entryIndex], entryIndex, collectionWasStarted); - return true; } catch (Exception ex) { @@ -198,6 +158,81 @@ private static bool HandleCollectionCoin(GatewayConnection connection, Collectio } } + private static bool TryAddCollectionEntry(GatewayConnection connection, int collectionId, int entryId, Action? onCollected = null) + { + if (!_resourceManager.Collections.TryGetValue(collectionId, out var collection)) + return false; + + var entryIndex = collection.Entries.FindIndex(entry => entry.Id == entryId); + + if (entryIndex < 0) + return false; + + var ownedItemDefinitionIds = connection.Player.Items + .Select(item => item.Definition) + .ToHashSet(); + var collectedEntryIds = connection.Player.CollectedCollectionEntryIds.ToHashSet(); + var collectionWasStarted = collection.IsStarted(ownedItemDefinitionIds, collectedEntryIds); + var characterId = GuidHelper.GetPlayerId(connection.Player.Guid); + + using var dbContext = _dbContextFactory.CreateDbContext(); + dbContext.CollectionEntries.Add(new DbCollectionEntry + { + CharacterId = characterId, + CollectionId = collectionId, + EntryId = entryId + }); + + if (dbContext.SaveChanges() <= 0) + return false; + + connection.Player.CollectedCollectionEntryIds.TryAdd(entryId); + collectedEntryIds.Add(entryId); + var collectionWillBeComplete = collection.IsComplete(ownedItemDefinitionIds, collectedEntryIds); + + onCollected?.Invoke(); + + if (collectionWasStarted && collectionWillBeComplete) + { + connection.SendTunneled(new ClientUpdatePacketCollectionRemoveEntry + { + CollectionId = collection.Id, + EntryId = entryId + }); + } + + SendCollectionEntryUpdate( + connection, collection, collection.Entries[entryIndex], entryIndex, collectionWasStarted); + return true; + } + + private static bool HandlePlayerCollectionNode(GatewayConnection connection, CollectionNode node) + { + var player = connection.Player; + + if (!_questManager.CanGatherQuestNode(player, node)) + return true; + + player.OnRemoveVisibleNpcGracefully(node, animate: true); + + var typeDefinition = node.TypeDefinition; + if (typeDefinition.CollectionEntryId != 0 && !player.CollectedCollectionEntryIds.Contains(typeDefinition.CollectionEntryId)) + { + try + { + TryAddCollectionEntry(connection, typeDefinition.CollectionId, typeDefinition.CollectionEntryId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to add collection entry {entryId} for node {nodeId} ({type}).", + typeDefinition.CollectionEntryId, node.SpawnDefinition.Id, typeDefinition.Key); + } + } + + _questManager.OnEvent(player, new NodeGatheredEvent(node)); + return true; + } + private static bool HandleCollectionNode(GatewayConnection connection, CollectionNode node) { var playerPosition = connection.Player.Position; @@ -218,6 +253,9 @@ private static bool HandleCollectionNode(GatewayConnection connection, Collectio return true; } + if (node.PoolDefinition.PlayerSpecific) + return HandlePlayerCollectionNode(connection, node); + if (!node.TryReserve()) return true; From 92b723523cf991f6bae66ed7c8796590b196dad6 Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 27 Oct 2026 13:15:05 -0500 Subject: [PATCH 2/5] Make the quest collection nodes player-specific, add their quest collections and fix the quest text --- src/Resources/CollectionNodePools.json | 34 +-- .../1/briarwood-tin-ore.json | 47 ---- src/Resources/CollectionNodeTypes.json | 46 ++-- src/Resources/Collections.json | 222 ++++++++++++++++++ src/Resources/Quests.json | 50 +--- 5 files changed, 276 insertions(+), 123 deletions(-) delete mode 100644 src/Resources/CollectionNodeSpawns/1/briarwood-tin-ore.json diff --git a/src/Resources/CollectionNodePools.json b/src/Resources/CollectionNodePools.json index 741943ac..c309d2c4 100644 --- a/src/Resources/CollectionNodePools.json +++ b/src/Resources/CollectionNodePools.json @@ -8104,69 +8104,71 @@ "ZoneDefinitionId": 1, "NodeType": "hastis-homework", "MaxActiveNodes": 8, - "RespawnSeconds": 30 + "RespawnSeconds": 30, + "PlayerSpecific": true }, { "Key": "briarwood-smashing-hammers", "ZoneDefinitionId": 1, "NodeType": "smashing-hammers", "MaxActiveNodes": 5, - "RespawnSeconds": 30 + "RespawnSeconds": 30, + "PlayerSpecific": true }, { "Key": "briarwood-smashing-chisels", "ZoneDefinitionId": 1, "NodeType": "smashing-chisels", "MaxActiveNodes": 5, - "RespawnSeconds": 30 + "RespawnSeconds": 30, + "PlayerSpecific": true }, { "Key": "briarwood-box-o-rocks", "ZoneDefinitionId": 1, "NodeType": "box-o-rocks", "MaxActiveNodes": 3, - "RespawnSeconds": 30 - }, - { - "Key": "briarwood-tin-ore", - "ZoneDefinitionId": 1, - "NodeType": "tin-ore", - "MaxActiveNodes": 5, - "RespawnSeconds": 30 + "RespawnSeconds": 30, + "PlayerSpecific": true }, { "Key": "pixie-nursery-woe-is-willie-flowers", "ZoneDefinitionId": 1, "NodeType": "woe-is-willie-flowers", "MaxActiveNodes": 1, - "RespawnSeconds": 10 + "RespawnSeconds": 10, + "PlayerSpecific": true }, { "Key": "merryvale-one-mans-trash-camp", "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-camp", "MaxActiveNodes": 1, - "RespawnSeconds": 10 + "RespawnSeconds": 10, + "PlayerSpecific": true }, { "Key": "merryvale-one-mans-trash-chugawug", "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-chugawug", "MaxActiveNodes": 1, - "RespawnSeconds": 10 + "RespawnSeconds": 10, + "PlayerSpecific": true }, { "Key": "merryvale-one-mans-trash-vendors", "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-vendors", "MaxActiveNodes": 1, - "RespawnSeconds": 10 + "RespawnSeconds": 10, + "PlayerSpecific": true }, { "Key": "merryvale-one-mans-trash-stage", "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-stage", "MaxActiveNodes": 1, - "RespawnSeconds": 10 + "RespawnSeconds": 10, + "PlayerSpecific": true } ] diff --git a/src/Resources/CollectionNodeSpawns/1/briarwood-tin-ore.json b/src/Resources/CollectionNodeSpawns/1/briarwood-tin-ore.json deleted file mode 100644 index 517ff81e..00000000 --- a/src/Resources/CollectionNodeSpawns/1/briarwood-tin-ore.json +++ /dev/null @@ -1,47 +0,0 @@ -[ - { - "Id": 949, - "Position": [ - -245.0, - -29.09, - -1166.0 - ], - "Heading": 0.0 - }, - { - "Id": 950, - "Position": [ - -252.0, - -30.24, - -1173.0 - ], - "Heading": 0.0 - }, - { - "Id": 951, - "Position": [ - -268.0, - -21.43, - -1159.0 - ], - "Heading": 0.0 - }, - { - "Id": 952, - "Position": [ - -226.0, - -24.65, - -1151.0 - ], - "Heading": 0.0 - }, - { - "Id": 953, - "Position": [ - -235.0, - -30.5, - -1183.0 - ], - "Heading": 0.0 - } -] diff --git a/src/Resources/CollectionNodeTypes.json b/src/Resources/CollectionNodeTypes.json index fc9d5220..7cb93ac0 100644 --- a/src/Resources/CollectionNodeTypes.json +++ b/src/Resources/CollectionNodeTypes.json @@ -160,7 +160,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 313, + "CollectionEntryId": 2204 }, { "Key": "smashing-hammers", @@ -170,7 +172,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 314, + "CollectionEntryId": 2205 }, { "Key": "smashing-chisels", @@ -180,7 +184,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 314, + "CollectionEntryId": 2206 }, { "Key": "box-o-rocks", @@ -190,17 +196,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 - }, - { - "Key": "tin-ore", - "Name": "Tin Ore", - "ModelId": 611, - "Scale": 1, - "CompositeEffectId": 5742, - "InteractRange": 12, - "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 315, + "CollectionEntryId": 2207 }, { "Key": "woe-is-willie-flowers", @@ -210,7 +208,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 317, + "CollectionEntryId": 2209 }, { "Key": "one-mans-trash-camp", @@ -220,7 +220,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 318, + "CollectionEntryId": 2210 }, { "Key": "one-mans-trash-chugawug", @@ -230,7 +232,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 318, + "CollectionEntryId": 2211 }, { "Key": "one-mans-trash-vendors", @@ -240,7 +244,9 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 318, + "CollectionEntryId": 2212 }, { "Key": "one-mans-trash-stage", @@ -250,6 +256,8 @@ "CompositeEffectId": 5742, "InteractRange": 12, "CursorId": 17, - "PlacementYOffset": 0 + "PlacementYOffset": 0, + "CollectionId": 318, + "CollectionEntryId": 2213 } ] diff --git a/src/Resources/Collections.json b/src/Resources/Collections.json index 9db8aeb5..d33612ac 100644 --- a/src/Resources/Collections.json +++ b/src/Resources/Collections.json @@ -9830,6 +9830,69 @@ } ] }, + { + "Id": 283, + "NameId": 390418, + "CategoryId": 9, + "DescriptionId": 390419, + "IconId": 459, + "IconTintId": 117, + "HeaderMetadata": 0, + "RewardIconId": 460, + "RewardCurrencyIconId": 414145, + "RewardEntryType": 1, + "RewardIconId2": 460, + "RewardEntryTintId": 0, + "RewardCurrencyIconId2": 414145, + "RewardPoints": 1, + "RewardMetadata": 68849, + "RewardEntryItemGuid": 0, + "Unknown20": 6083, + "Entries": [ + { + "Id": 2006, + "NameId": 390420, + "IconId": 457, + "IconTintId": 117, + "ItemDefinitionId": 1842 + }, + { + "Id": 2007, + "NameId": 390421, + "IconId": 457, + "IconTintId": 117, + "ItemDefinitionId": 1843 + }, + { + "Id": 2008, + "NameId": 390422, + "IconId": 457, + "IconTintId": 117, + "ItemDefinitionId": 1844 + }, + { + "Id": 2009, + "NameId": 390423, + "IconId": 457, + "IconTintId": 117, + "ItemDefinitionId": 1845 + }, + { + "Id": 2010, + "NameId": 390424, + "IconId": 457, + "IconTintId": 117, + "ItemDefinitionId": 1846 + }, + { + "Id": 2011, + "NameId": 390425, + "IconId": 457, + "IconTintId": 117, + "ItemDefinitionId": 1847 + } + ] + }, { "Id": 284, "NameId": 4404, @@ -10465,5 +10528,164 @@ "IconTintId": 1 } ] + }, + { + "Id": 313, + "NameId": 74437, + "CategoryId": 9, + "DescriptionId": 74441, + "IconId": 967, + "IconTintId": 0, + "HeaderMetadata": 0, + "RewardIconId": 3644, + "RewardCurrencyIconId": 2620, + "RewardEntryType": 1, + "RewardIconId2": 3644, + "RewardEntryTintId": 234, + "RewardCurrencyIconId2": 2620, + "RewardPoints": 1, + "RewardMetadata": 17300, + "RewardEntryItemGuid": 0, + "Unknown20": 5307, + "Entries": [ + { + "Id": 2204, + "NameId": 74449, + "IconId": 967, + "IconTintId": 0 + } + ] + }, + { + "Id": 314, + "NameId": 70218, + "CategoryId": 9, + "DescriptionId": 70219, + "IconId": 3243, + "IconTintId": 0, + "HeaderMetadata": 0, + "RewardIconId": 3644, + "RewardCurrencyIconId": 2620, + "RewardEntryType": 1, + "RewardIconId2": 3644, + "RewardEntryTintId": 234, + "RewardCurrencyIconId2": 2620, + "RewardPoints": 1, + "RewardMetadata": 17300, + "RewardEntryItemGuid": 0, + "Unknown20": 5307, + "Entries": [ + { + "Id": 2205, + "NameId": 70221, + "IconId": 3243, + "IconTintId": 0 + }, + { + "Id": 2206, + "NameId": 70227, + "IconId": 3239, + "IconTintId": 0 + } + ] + }, + { + "Id": 315, + "NameId": 70201, + "CategoryId": 9, + "DescriptionId": 70202, + "IconId": 5260, + "IconTintId": 0, + "HeaderMetadata": 0, + "RewardIconId": 3644, + "RewardCurrencyIconId": 2620, + "RewardEntryType": 1, + "RewardIconId2": 3644, + "RewardEntryTintId": 234, + "RewardCurrencyIconId2": 2620, + "RewardPoints": 1, + "RewardMetadata": 17300, + "RewardEntryItemGuid": 0, + "Unknown20": 5307, + "Entries": [ + { + "Id": 2207, + "NameId": 70289, + "IconId": 5260, + "IconTintId": 0 + } + ] + }, + { + "Id": 317, + "NameId": 36873, + "CategoryId": 9, + "DescriptionId": 36874, + "IconId": 607, + "IconTintId": 0, + "HeaderMetadata": 0, + "RewardIconId": 3644, + "RewardCurrencyIconId": 2620, + "RewardEntryType": 1, + "RewardIconId2": 3644, + "RewardEntryTintId": 234, + "RewardCurrencyIconId2": 2620, + "RewardPoints": 1, + "RewardMetadata": 17300, + "RewardEntryItemGuid": 0, + "Unknown20": 5307, + "Entries": [ + { + "Id": 2209, + "NameId": 36876, + "IconId": 607, + "IconTintId": 0 + } + ] + }, + { + "Id": 318, + "NameId": 47534, + "CategoryId": 9, + "DescriptionId": 47535, + "IconId": 1280, + "IconTintId": 0, + "HeaderMetadata": 0, + "RewardIconId": 3644, + "RewardCurrencyIconId": 2620, + "RewardEntryType": 1, + "RewardIconId2": 3644, + "RewardEntryTintId": 234, + "RewardCurrencyIconId2": 2620, + "RewardPoints": 1, + "RewardMetadata": 17300, + "RewardEntryItemGuid": 0, + "Unknown20": 5307, + "Entries": [ + { + "Id": 2210, + "NameId": 47543, + "IconId": 1280, + "IconTintId": 0 + }, + { + "Id": 2211, + "NameId": 47545, + "IconId": 1280, + "IconTintId": 0 + }, + { + "Id": 2212, + "NameId": 47547, + "IconId": 1280, + "IconTintId": 0 + }, + { + "Id": 2213, + "NameId": 47549, + "IconId": 1280, + "IconTintId": 0 + } + ] } ] diff --git a/src/Resources/Quests.json b/src/Resources/Quests.json index 35b24a25..02e0ca28 100644 --- a/src/Resources/Quests.json +++ b/src/Resources/Quests.json @@ -123,9 +123,9 @@ "Name": "Hasti's Homework Heist", "QuestId": 3002, "TitleId": 74437, - "DescriptionId": 74456, + "DescriptionId": 74438, "GiverDialogueId": 74436, - "ObjectiveDescriptionId": 74456, + "ObjectiveDescriptionId": 74441, "IconId": 43278, "GiverNpcId": 2335, "TargetNpcId": 2335, @@ -137,8 +137,8 @@ "NotificationActive": 6, "Goals": [ { - "NameId": 46489, - "DescriptionId": 74456, + "NameId": 74443, + "DescriptionId": 74444, "Type": 2, "RequiredCount": 8, "CollectNodeType": "hastis-homework" @@ -498,7 +498,7 @@ "TitleId": 38385, "DescriptionId": 38386, "GiverDialogueId": 38384, - "ObjectiveDescriptionId": 38386, + "ObjectiveDescriptionId": 38392, "IconId": 43278, "GiverNpcId": 3145, "TargetNpcId": 3145, @@ -552,7 +552,7 @@ "TitleId": 70107, "DescriptionId": 70106, "GiverDialogueId": 70102, - "ObjectiveDescriptionId": 70106, + "ObjectiveDescriptionId": 70109, "IconId": 43278, "GiverNpcId": 3145, "TargetNpcId": 3120, @@ -577,9 +577,9 @@ "Name": "Tools for Smashing", "QuestId": 3042, "TitleId": 70218, - "DescriptionId": 70219, + "DescriptionId": 70301, "GiverDialogueId": 70299, - "ObjectiveDescriptionId": 70301, + "ObjectiveDescriptionId": 70219, "IconId": 43278, "GiverNpcId": 3120, "TargetNpcId": 3120, @@ -620,7 +620,7 @@ "TitleId": 70201, "DescriptionId": 70202, "GiverDialogueId": 70505, - "ObjectiveDescriptionId": 70202, + "ObjectiveDescriptionId": 70288, "IconId": 43278, "GiverNpcId": 3120, "TargetNpcId": 3145, @@ -691,38 +691,6 @@ } ] }, - { - "Name": "A Booming Problem", - "QuestId": 3061, - "TitleId": 400355, - "DescriptionId": 400356, - "GiverDialogueId": 400433, - "ObjectiveDescriptionId": 400435, - "IconId": 43278, - "GiverNpcId": 1925, - "TargetNpcId": 1925, - "RewardCoins": 30, - "RewardExperience": 90, - "RewardItems": [15916], - "NotificationAvailable": 2, - "NotificationActive": 6, - "Goals": [ - { - "NameId": 400357, - "DescriptionId": 400358, - "Type": 2, - "RequiredCount": 5, - "CollectNodeType": "tin-ore" - }, - { - "Type": 0, - "NameId": 400426, - "DescriptionId": 400427, - "DialogueId": 400436, - "Targets": [{ "NpcId": 1925 }] - } - ] - }, { "Name": "The Party Pooper", "QuestId": 3070, From 6dacfce49ef0c50155357a6ba1e4d941e6107e3d Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 27 Oct 2026 13:37:13 -0500 Subject: [PATCH 3/5] Send the collection id when a quest offer or turn-in lists a collection reward --- src/Sanctuary.Game/Quests/QuestManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Sanctuary.Game/Quests/QuestManager.cs b/src/Sanctuary.Game/Quests/QuestManager.cs index 52c5b977..e2d9396d 100644 --- a/src/Sanctuary.Game/Quests/QuestManager.cs +++ b/src/Sanctuary.Game/Quests/QuestManager.cs @@ -748,7 +748,6 @@ private void Offer(Player player, QuestDefinition quest, int delayMs = 0) private void FillRewardBundle(RewardBundleBase bundle, QuestDefinition quest) { - bundle.Success = false; bundle.Coins = quest.RewardCoins; bundle.Experience = quest.RewardExperience; bundle.Unknown3 = 0; @@ -758,6 +757,8 @@ private void FillRewardBundle(RewardBundleBase bundle, QuestDefinition quest) foreach (var entry in BuildRewardEntries(quest)) bundle.Entries.Add(entry); + + bundle.Success = bundle.Entries.Any(entry => entry is RewardBundleEntryCollectionAdd); } private List BuildRewardEntries(QuestDefinition quest) From f3956028ba47e5e962811713f048922d7a1e65e8 Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 27 Oct 2026 13:37:13 -0500 Subject: [PATCH 4/5] Show the quest collections as quest rewards --- src/Resources/Quests.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Resources/Quests.json b/src/Resources/Quests.json index 02e0ca28..51874b85 100644 --- a/src/Resources/Quests.json +++ b/src/Resources/Quests.json @@ -135,6 +135,7 @@ "PrerequisiteQuestId": 3001, "NotificationAvailable": 2, "NotificationActive": 6, + "RewardCollectionId": 313, "Goals": [ { "NameId": 74443, @@ -590,6 +591,7 @@ "NextQuestId": 3043, "NotificationAvailable": 2, "NotificationActive": 6, + "RewardCollectionId": 314, "Goals": [ { "NameId": 70221, @@ -630,6 +632,7 @@ "PrerequisiteQuestId": 3042, "NotificationAvailable": 2, "NotificationActive": 6, + "RewardCollectionId": 315, "Goals": [ { "NameId": 70289, @@ -2185,6 +2188,7 @@ "PrerequisiteQuestId": 3301, "NotificationAvailable": 2, "NotificationActive": 6, + "RewardCollectionId": 317, "Goals": [ { "Type": 2, @@ -2225,6 +2229,7 @@ "NotificationAvailable": 2, "NotificationActive": 6, "HideObjectiveTarget": true, + "RewardCollectionId": 318, "Goals": [ { "Type": 2, From d47d282eef2a550985cfd1cb4854edc08afb29e8 Mon Sep 17 00:00:00 2001 From: JadenY Date: Tue, 27 Oct 2026 20:57:13 -0500 Subject: [PATCH 5/5] Drop the cast in CollectGoal, warn about collect goals without a player-specific pool and remove unused respawn times --- src/Resources/CollectionNodePools.json | 9 --------- src/Sanctuary.Game/ResourceManager.cs | 16 ++++++++++++++++ .../Definitions/QuestGoals/CollectGoal.cs | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/Resources/CollectionNodePools.json b/src/Resources/CollectionNodePools.json index c309d2c4..ff1e1ae5 100644 --- a/src/Resources/CollectionNodePools.json +++ b/src/Resources/CollectionNodePools.json @@ -8104,7 +8104,6 @@ "ZoneDefinitionId": 1, "NodeType": "hastis-homework", "MaxActiveNodes": 8, - "RespawnSeconds": 30, "PlayerSpecific": true }, { @@ -8112,7 +8111,6 @@ "ZoneDefinitionId": 1, "NodeType": "smashing-hammers", "MaxActiveNodes": 5, - "RespawnSeconds": 30, "PlayerSpecific": true }, { @@ -8120,7 +8118,6 @@ "ZoneDefinitionId": 1, "NodeType": "smashing-chisels", "MaxActiveNodes": 5, - "RespawnSeconds": 30, "PlayerSpecific": true }, { @@ -8128,7 +8125,6 @@ "ZoneDefinitionId": 1, "NodeType": "box-o-rocks", "MaxActiveNodes": 3, - "RespawnSeconds": 30, "PlayerSpecific": true }, { @@ -8136,7 +8132,6 @@ "ZoneDefinitionId": 1, "NodeType": "woe-is-willie-flowers", "MaxActiveNodes": 1, - "RespawnSeconds": 10, "PlayerSpecific": true }, { @@ -8144,7 +8139,6 @@ "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-camp", "MaxActiveNodes": 1, - "RespawnSeconds": 10, "PlayerSpecific": true }, { @@ -8152,7 +8146,6 @@ "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-chugawug", "MaxActiveNodes": 1, - "RespawnSeconds": 10, "PlayerSpecific": true }, { @@ -8160,7 +8153,6 @@ "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-vendors", "MaxActiveNodes": 1, - "RespawnSeconds": 10, "PlayerSpecific": true }, { @@ -8168,7 +8160,6 @@ "ZoneDefinitionId": 1, "NodeType": "one-mans-trash-stage", "MaxActiveNodes": 1, - "RespawnSeconds": 10, "PlayerSpecific": true } ] diff --git a/src/Sanctuary.Game/ResourceManager.cs b/src/Sanctuary.Game/ResourceManager.cs index b7c1763c..3314f74c 100644 --- a/src/Sanctuary.Game/ResourceManager.cs +++ b/src/Sanctuary.Game/ResourceManager.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Sanctuary.Game.Resources; +using Sanctuary.Game.Resources.Definitions.QuestGoals; using Sanctuary.Game.Resources.Definitions.Rewards; namespace Sanctuary.Game; @@ -377,6 +378,21 @@ public bool Load() if (!Quests.Load(QuestsFile)) return false; + var playerSpecificNodeTypes = CollectionNodePools.Values + .Where(pool => pool.PlayerSpecific) + .Select(pool => pool.NodeType) + .ToHashSet(); + + foreach (var quest in Quests.Quests.Values) + { + foreach (var collectGoal in quest.Goals.OfType()) + { + if (!string.IsNullOrEmpty(collectGoal.CollectNodeType) && !playerSpecificNodeTypes.Contains(collectGoal.CollectNodeType)) + _logger.LogWarning("Collect goal on quest {id} uses node type {type}, which has no player-specific pool; it can never be credited.", + quest.QuestId, collectGoal.CollectNodeType); + } + } + foreach (var coin in CollectionCoins.Values) { Collections.TryGetValue(coin.CollectionId, out var collection); diff --git a/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs b/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs index a1a2f06d..b958b778 100644 --- a/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs +++ b/src/Sanctuary.Game/Resources/Definitions/QuestGoals/CollectGoal.cs @@ -25,10 +25,10 @@ public override bool Matches(QuestDefinition quest, QuestEvent questEvent) public override QuestGoalResult Progress(QuestDefinition quest, QuestEvent questEvent, GoalProgress progress) { - if (!Matches(quest, questEvent)) + if (!Matches(quest, questEvent) || questEvent is not NodeGatheredEvent { Node: var node }) return QuestGoalResult.None; - if (progress is not CollectProgress collectProgress || !collectProgress.TryAdd(((NodeGatheredEvent)questEvent).Node.SpawnDefinition.Id)) + if (progress is not CollectProgress collectProgress || !collectProgress.TryAdd(node.SpawnDefinition.Id)) return QuestGoalResult.None; return progress.Count >= RequiredCount ? QuestGoalResult.Completed : QuestGoalResult.Progressed;