From 11ae50875abbfcfe19a5cd3e5b8c18e88024132a Mon Sep 17 00:00:00 2001
From: Cj <161484149+CJ-SPT@users.noreply.github.com>
Date: Sun, 14 Sep 2025 04:20:25 -0400
Subject: [PATCH] Custom quest service (#589)
* Initial work
* add todo
* Fix up errors
* More work on CustomQuestService
* Fix mistake
* Remove cloning work, its cancer
* clean-up
* Use TryAdd as a guard
* localize errors
* remove unused exception
* fix using
* fix not passing logging params
---
.../SPT_Data/database/locales/server/en.json | 6 +
.../Models/Spt/Mod/NewQuestDetails.cs | 49 +++++++
.../Services/Mod/CustomQuestService.cs | 121 ++++++++++++++++++
3 files changed, 176 insertions(+)
create mode 100644 Libraries/SPTarkov.Server.Core/Models/Spt/Mod/NewQuestDetails.cs
create mode 100644 Libraries/SPTarkov.Server.Core/Services/Mod/CustomQuestService.cs
diff --git a/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json b/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json
index 2c3d87c9..feb67e91 100644
--- a/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json
+++ b/Libraries/SPTarkov.Server.Assets/SPT_Data/database/locales/server/en.json
@@ -62,6 +62,12 @@
"chat-unable_to_register_command_already_registered": "Unable to register already registered command: %s",
"client_request": "[Client Request] %s",
"client_request_ip": "[Client Request] {{ip}} {{url}}",
+ "custom-quest-service_quest_id_already_exists": "A quest with the id: {{questId}} already exists.",
+ "custom-quest-service_no_languages_for_quest": "No languages have been added for custom quest id: {{questId}}",
+ "custom-quest-service_no_entries_for_language": "No locale entries have been added for language key: {{languageKey}}, was this intentional?",
+ "custom-quest-service_could_not_find_language_key": "Could not find language key: {{languageKey}} in global locales when adding a custom quest. This is either a typo, or this language is not supported.",
+ "custom-quest-service_locale_data_null": "Locale data is null for language: {{languageKey}}",
+ "custom-quest-service_invalid_side": "QuestId: {{questId}} Savage is not a valid side for a side locked quest.",
"customisation-item_already_purchased": "Clothing item {{itemId}} {{itemName}} already purchased",
"customisation-suit_lacks_upd_or_stack_property": "Suit with tpl: %s lacks a upd object or stackobjectcount property",
"customisation-unable_to_find_clothing_item_in_inventory": "Clothing item not found in inventory with id: %s",
diff --git a/Libraries/SPTarkov.Server.Core/Models/Spt/Mod/NewQuestDetails.cs b/Libraries/SPTarkov.Server.Core/Models/Spt/Mod/NewQuestDetails.cs
new file mode 100644
index 00000000..e5ee3806
--- /dev/null
+++ b/Libraries/SPTarkov.Server.Core/Models/Spt/Mod/NewQuestDetails.cs
@@ -0,0 +1,49 @@
+using System.Text.Json.Serialization;
+using SPTarkov.Server.Core.Models.Common;
+using SPTarkov.Server.Core.Models.Eft.Common.Tables;
+using SPTarkov.Server.Core.Models.Enums;
+
+namespace SPTarkov.Server.Core.Models.Spt.Mod;
+
+///
+/// New quest detail object for use with the CustomQuestService.
+///
+public record NewQuestDetails
+{
+ ///
+ /// Quest to be added to the database
+ ///
+ [JsonPropertyName("newQuest")]
+ public required Quest NewQuest { get; init; }
+
+ ///
+ /// Locales for this quest. The primary key is the language to add to locale entries to
+ /// The secondary key is the locale key, the value is the locale text itself.
+ ///
+ [JsonPropertyName("locales")]
+ public required Dictionary> Locales { get; init; }
+
+ ///
+ /// Only Usec and Bear are valid entries here,
+ /// if used it will lock that quest to only being available to that specific side.
+ ///
+ /// If not used, this should be left null to keep the quest open to both Usec and Bears.
+ ///
+ [JsonPropertyName("lockedToSide")]
+ public PlayerSide? LockedToSide { get; init; }
+}
+
+///
+/// Result from either creating a new quest or cloning one.
+///
+public record CreateQuestResult(bool Success, MongoId? QuestId)
+{
+ [JsonPropertyName("success")]
+ public bool Success { get; set; } = Success;
+
+ [JsonPropertyName("questId")]
+ public MongoId? QuestId { get; set; } = QuestId;
+
+ [JsonPropertyName("errors")]
+ public List Errors { get; } = [];
+}
diff --git a/Libraries/SPTarkov.Server.Core/Services/Mod/CustomQuestService.cs b/Libraries/SPTarkov.Server.Core/Services/Mod/CustomQuestService.cs
new file mode 100644
index 00000000..230b96c9
--- /dev/null
+++ b/Libraries/SPTarkov.Server.Core/Services/Mod/CustomQuestService.cs
@@ -0,0 +1,121 @@
+using SPTarkov.DI.Annotations;
+using SPTarkov.Server.Core.Models.Common;
+using SPTarkov.Server.Core.Models.Enums;
+using SPTarkov.Server.Core.Models.Spt.Config;
+using SPTarkov.Server.Core.Models.Spt.Mod;
+using SPTarkov.Server.Core.Servers;
+
+namespace SPTarkov.Server.Core.Services.Mod;
+
+[Injectable]
+public class CustomQuestService(
+ DatabaseService databaseService,
+ ConfigServer configServer,
+ ServerLocalisationService serverLocalisationService
+)
+{
+ ///
+ /// Create a new custom quest from a NewQuestDetails object.
+ ///
+ /// Quest details to be used for creation
+ /// Result of the quest creation, remember to check it for errors!
+ public CreateQuestResult CreateQuest(NewQuestDetails newQuestDetails)
+ {
+ var quest = newQuestDetails.NewQuest;
+ var result = new CreateQuestResult(false, newQuestDetails.NewQuest.Id);
+
+ var databaseQuests = databaseService.GetTables().Templates.Quests;
+ if (!databaseQuests.TryAdd(quest.Id, quest))
+ {
+ result.Errors.Add(serverLocalisationService.GetText("custom-quest-service_quest_id_already_exists", quest.Id));
+ return result;
+ }
+
+ var locales = newQuestDetails.Locales;
+ if (locales.Count == 0)
+ {
+ result.Errors.Add(serverLocalisationService.GetText("custom-quest-service_no_languages_for_quest", quest.Id));
+ return result;
+ }
+
+ AddQuestLocales(locales, result);
+
+ var side = newQuestDetails.LockedToSide;
+ if (side.HasValue)
+ {
+ RestrictQuestSide(quest.Id, side.Value, result);
+ }
+
+ // No errors mean success
+ result.Success = result.Errors.Count == 0;
+ return result;
+ }
+
+ ///
+ /// Adds quest locales to the database
+ ///
+ /// locales to add
+ /// create quest result
+ private void AddQuestLocales(Dictionary> locales, CreateQuestResult result)
+ {
+ var globalLocales = databaseService.GetLocales().Global;
+
+ foreach (var (languageKey, entries) in locales)
+ {
+ if (entries.Count == 0)
+ {
+ result.Errors?.Add(serverLocalisationService.GetText("custom-quest-service_no_entries_for_language", languageKey));
+ continue;
+ }
+
+ if (!globalLocales.TryGetValue(languageKey, out var lazyLoadedLocales))
+ {
+ result.Errors?.Add(serverLocalisationService.GetText("custom-quest-service_could_not_find_language_key", languageKey));
+ continue;
+ }
+
+ lazyLoadedLocales.AddTransformer(localeData =>
+ {
+ if (localeData is null)
+ {
+ result.Errors?.Add(serverLocalisationService.GetText("custom-quest-service_locale_data_null", languageKey));
+ return null;
+ }
+
+ foreach (var (key, entry) in entries)
+ {
+ localeData.TryAdd(key, entry);
+ }
+
+ return localeData;
+ });
+ }
+ }
+
+ ///
+ /// Restricts a custom quest to a specific side.
+ ///
+ /// Quest id to restrict
+ /// Side to restrict it to
+ /// Result of the quest creation
+ private void RestrictQuestSide(MongoId questId, PlayerSide side, CreateQuestResult result)
+ {
+ var questConfig = configServer.GetConfig();
+
+ // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
+ switch (side)
+ {
+ case PlayerSide.Usec:
+ questConfig.UsecOnlyQuests.Add(questId);
+ break;
+
+ case PlayerSide.Bear:
+ questConfig.BearOnlyQuests.Add(questId);
+ break;
+
+ case PlayerSide.Savage:
+ result.Errors.Add(serverLocalisationService.GetText("custom-quest-service_invalid_side", result.QuestId));
+ break;
+ }
+ }
+}