diff --git a/ModExamples/13.1AddTraderWithDynamicAssorts/13.1AddTraderWithDynamicAssorts.csproj b/ModExamples/13.1AddTraderWithDynamicAssorts/13.1AddTraderWithDynamicAssorts.csproj
new file mode 100644
index 00000000..7c4167ac
--- /dev/null
+++ b/ModExamples/13.1AddTraderWithDynamicAssorts/13.1AddTraderWithDynamicAssorts.csproj
@@ -0,0 +1,32 @@
+
+
+
+ net9.0
+ _13._1AddTraderWithDynamicAssorts
+ enable
+ enable
+ Library
+
+
+
+
+
+ ..\TempReferences\Core.dll
+ false
+ false
+ false
+
+
+ ..\TempReferences\SptCommon.dll
+ false
+ false
+ false
+
+
+ ..\TempReferences\SptDependencyInjection.dll
+ false
+ false
+ false
+
+
+
diff --git a/ModExamples/13.1AddTraderWithDynamicAssorts/AddTraderHelper.cs b/ModExamples/13.1AddTraderWithDynamicAssorts/AddTraderHelper.cs
new file mode 100644
index 00000000..106e30d8
--- /dev/null
+++ b/ModExamples/13.1AddTraderWithDynamicAssorts/AddTraderHelper.cs
@@ -0,0 +1,184 @@
+using Core.Models.Common;
+using Core.Models.Eft.Common.Tables;
+using Core.Models.Spt.Config;
+using Core.Models.Spt.Server;
+using Core.Utils;
+
+namespace _13._1AddTraderWithDynamicAssorts
+{
+ public class AddTraderHelper
+ {
+ /**
+ * Add record to trader config to set the refresh time of trader in seconds (default is 60 minutes)
+ * @param traderConfig trader config to add our trader to
+ * @param baseJson json file for trader (db/base.json)
+ * @param refreshTimeSecondsMin How many seconds between trader stock refresh min time
+ * @param refreshTimeSecondsMax How many seconds between trader stock refresh max time
+ */
+ public void SetTraderUpdateTime(TraderConfig traderConfig, dynamic baseJson, int refreshTimeSecondsMin, int refreshTimeSecondsMax)
+ {
+ // Add refresh time in seconds to config
+ var traderRefreshRecord = new UpdateTime
+ {
+ TraderId = baseJson.id,
+ Seconds = new MinMax(refreshTimeSecondsMin, refreshTimeSecondsMax)
+ };
+
+
+ traderConfig.UpdateTime.Add(traderRefreshRecord);
+ }
+
+ /**
+ * Add our new trader to the database
+ * @param traderDetailsToAdd trader details
+ * @param tables database
+ * @param jsonUtil json utility class
+ */
+ public void AddTraderToDb(dynamic traderDetailsToAdd, DatabaseTables tables, JsonUtil jsonUtil, object assortJson)
+ {
+ // Create trader data ready to add to database
+ var traderDataToAdd = new Trader
+ {
+ Assort =
+ jsonUtil.Deserialize(
+ jsonUtil.Serialize(assortJson)), // Deserialise/serialise creates a copy of the json
+ Base =
+ jsonUtil.Deserialize(
+ jsonUtil.Serialize(traderDetailsToAdd)), // Deserialise/serialise creates a copy of the json
+ QuestAssort = new Dictionary> // questassort is empty as trader has no assorts unlocked by quests
+ {
+ { "Started", new Dictionary() },
+ { "Success", new Dictionary() },
+ { "Fail", new Dictionary() }
+ }
+ };
+
+ // Add trader to trader table, key is the traders id
+ tables.Traders.Add(traderDetailsToAdd._id, traderDataToAdd);
+ }
+
+ /**
+ * Add traders name/location/description to the locale table
+ * @param baseJson json file for trader (db/base.json)
+ * @param tables database tables
+ * @param fullName Complete name of trader
+ * @param firstName First name of trader
+ * @param nickName Nickname of trader
+ * @param location Location of trader (e.g. "Here in the cat shop")
+ * @param description Description of trader
+ */
+ public void AddTraderToLocales(dynamic baseJson, DatabaseTables tables, string fullName, string firstName, string nickName, string location, string description)
+ {
+ // For each language, add locale for the new trader
+ var locales = tables.Locales.Global;
+
+ foreach (var (key, value) in locales) {
+ value.Value[$"{baseJson._id} FullName"] = fullName;
+ value.Value[$"{baseJson._id} FirstName"] = firstName;
+ value.Value[$"{baseJson._id} Nickname"] = nickName;
+ value.Value[$"{baseJson._id} Location"] = location;
+ value.Value[$"{baseJson._id} Description"] = description;
+ }
+ }
+
+ public List- CreateGlock()
+ {
+ // Create an array ready to hold weapon + all mods
+ var glock = new List
- ();
+
+ // Add the base first
+ glock.Add(new Item { // Add the base weapon first
+ Id =
+ NewItemIds.GLOCK_BASE, // Ids matter, MUST BE UNIQUE See mod.ts for more details
+ Template =
+ "5a7ae0c351dfba0017554310", // This is the weapons tpl, found on: https://db.sp-tarkov.com/search
+ });
+
+ // Add barrel
+ glock.Add(new Item {
+ Id =
+ NewItemIds.GLOCK_BARREL,
+ Template =
+ "5a6b60158dc32e000a31138b",
+ ParentId =
+ NewItemIds.GLOCK_BASE, // This is a sub item, you need to define its parent its attached to / inserted into
+ SlotId =
+ "mod_barrel", // Required for mods, you need to define what 'slot' the mod will fill on the weapon
+ });
+
+ // Add receiver
+ glock.Add( new Item {
+ Id =
+ NewItemIds.GLOCK_RECIEVER,
+ Template =
+ "5a9685b1a2750c0032157104",
+ ParentId =
+ NewItemIds.GLOCK_BASE,
+ SlotId =
+ "mod_reciever",
+ });
+
+ // Add compensator
+ glock.Add(new Item {
+ Id =
+ NewItemIds.GLOCK_COMPENSATOR,
+ Template =
+ "5a7b32a2e899ef00135e345a",
+ ParentId =
+ NewItemIds.GLOCK_RECIEVER, // The parent of this mod is the receiver NOT weapon, be careful to get the correct parent
+ SlotId =
+ "mod_muzzle",
+ });
+
+ // Add Pistol grip
+ glock.Add(new Item {
+ Id =
+ NewItemIds.GLOCK_PISTOL_GRIP,
+ Template =
+ "5a7b4960e899ef197b331a2d",
+ ParentId =
+ NewItemIds.GLOCK_BASE,
+ SlotId =
+ "mod_pistol_grip",
+ });
+
+ // Add front sight
+ glock.Add(new Item {
+ Id =
+ NewItemIds.GLOCK_FRONT_SIGHT,
+ Template =
+ "5a6f5d528dc32e00094b97d9",
+ ParentId =
+ NewItemIds.GLOCK_RECIEVER,
+ SlotId =
+ "mod_sight_rear",
+ });
+
+ // Add rear sight
+ glock.Add(new Item {
+ Id =
+ NewItemIds.GLOCK_REAR_SIGHT,
+ Template =
+ "5a6f58f68dc32e000a311390",
+ ParentId =
+ NewItemIds.GLOCK_RECIEVER,
+ SlotId =
+ "mod_sight_front",
+ });
+
+ // Add magazine
+ glock.Add(new Item {
+ Id =
+ NewItemIds.GLOCK_MAGAZINE,
+ Template =
+ "630769c4962d0247b029dc60",
+ ParentId =
+ NewItemIds.GLOCK_BASE,
+ SlotId =
+ "mod_magazine",
+ });
+
+ return glock;
+ }
+ }
+}
diff --git a/ModExamples/13.1AddTraderWithDynamicAssorts/AddTraderWithDynamicAssorts.cs b/ModExamples/13.1AddTraderWithDynamicAssorts/AddTraderWithDynamicAssorts.cs
new file mode 100644
index 00000000..0ce41686
--- /dev/null
+++ b/ModExamples/13.1AddTraderWithDynamicAssorts/AddTraderWithDynamicAssorts.cs
@@ -0,0 +1,125 @@
+using Core.Models.Eft.Common.Tables;
+using Core.Models.Enums;
+using Core.Models.External;
+using Core.Models.Spt.Config;
+using Core.Models.Utils;
+using Core.Routers;
+using Core.Servers;
+using Core.Services;
+using Core.Utils;
+
+namespace _13._1AddTraderWithDynamicAssorts
+{
+ public class AddTraderWithDynamicAssorts : IPostDBLoadMod
+ {
+ private readonly ISptLogger _logger;
+ private readonly HashUtil _hashUtil;
+ private readonly JsonUtil _jsonUtil;
+ private readonly FileUtil _fileUtil;
+ private readonly DatabaseService _databaseService;
+ private readonly ImageRouter _imageRouter;
+ private readonly ConfigServer _configServer;
+ private readonly TraderConfig _traderConfig;
+ private readonly RagfairConfig _ragfairConfig;
+
+ public AddTraderWithDynamicAssorts(
+ ISptLogger logger,
+ HashUtil hashUtil,
+ JsonUtil jsonUtil,
+ FileUtil fileUtil,
+ DatabaseService databaseService,
+ ImageRouter imageRouter,
+ ConfigServer configServer)
+ {
+ _logger = logger;
+ _hashUtil = hashUtil;
+ _jsonUtil = jsonUtil;
+ _fileUtil = fileUtil;
+ _databaseService = databaseService;
+ _imageRouter = imageRouter;
+ _configServer = configServer;
+
+ _traderConfig = _configServer.GetConfig();
+ _ragfairConfig = _configServer.GetConfig();
+ }
+
+ public void PostDBLoad()
+ {
+ var traderImagePath = "./db/cat.jpg";
+
+ var baseJson = _fileUtil.ReadFile("./db/base.json");
+ var traderBase = _jsonUtil.Deserialize(baseJson);
+
+ // Create helper class and use it to register our traders image/icon + set its stock refresh time
+ var addTraderHelper = new AddTraderHelper();
+ _imageRouter.AddRoute(traderBase.Avatar.Replace(".jpg", ""), System.IO.Path.GetFullPath(traderImagePath));
+ addTraderHelper.SetTraderUpdateTime(_traderConfig, traderBase, 3600, 4000);
+
+ // Add trader to flea market
+ _ragfairConfig.Traders[traderBase.Id] = true;
+
+ // Get a reference to the database tables
+ var tables = _databaseService.GetTables();
+
+ var fluentAssortCreator = new FluentTraderAssortCreator(_logger, _hashUtil);
+
+ // Add milk
+ var milkAssort = fluentAssortCreator
+ .CreateSingleAssortItem(ItemTpl.DRINK_PACK_OF_MILK)
+ .AddStackCount(200)
+ .AddBuyRestriction(10)
+ .AddMoneyCost(Money.ROUBLES, 2000)
+ .AddLoyaltyLevel(1)
+ .Export(tables.Traders[traderBase.Id]);
+
+ // Add 3x bitcoin + salewa for milk barter
+ fluentAssortCreator
+ .CreateSingleAssortItem(ItemTpl.DRINK_PACK_OF_MILK)
+ .AddStackCount(100)
+ .AddBarterCost(ItemTpl.BARTER_PHYSICAL_BITCOIN, 3)
+ .AddBarterCost(ItemTpl.MEDKIT_SALEWA_FIRST_AID_KIT, 1)
+ .AddLoyaltyLevel(1)
+ .Export(tables.Traders[traderBase.Id]);
+
+
+ // Add glock as a money purchase
+ fluentAssortCreator
+ .CreateComplexAssortItem(addTraderHelper.CreateGlock())
+ .AddUnlimitedStackCount()
+ .AddMoneyCost(Money.ROUBLES, 20000)
+ .AddBuyRestriction(3)
+ .AddLoyaltyLevel(1)
+ .Export(tables.Traders[traderBase.Id]);
+
+ // Add mp133 preset as a barter for mayonase
+ fluentAssortCreator
+ .CreateComplexAssortItem(tables.Globals.ItemPresets["584148f2245977598f1ad387"].Items) // Weapon preset id comes from globals.json
+ .AddStackCount(200)
+ .AddBarterCost(ItemTpl.FOOD_JAR_OF_DEVILDOG_MAYO, 1)
+ .AddBuyRestriction(3)
+ .AddLoyaltyLevel(1)
+ .Export(tables.Traders[traderBase.Id]);
+
+ addTraderHelper.AddTraderToLocales(
+ baseJson,
+ _databaseService.GetTables(),
+ traderBase.Name,
+ "Cat",
+ traderBase.Nickname,
+ traderBase.Location,
+ "This is the cat shop. Meow.");
+ }
+ }
+
+ public static class NewItemIds
+ {
+ public static string GLOCK_BASE = "66eeef3b2a166b73d2066a74";
+ public static string GLOCK_BARREL = "66eeef3b2a166b73d2066a75";
+ public static string GLOCK_RECIEVER = "66eeef3b2a166b73d2066a76";
+ public static string GLOCK_COMPENSATOR = "66eeef3b2a166b73d2066a77";
+ public static string GLOCK_PISTOL_GRIP = "66eeef3b2a166b73d2066a78";
+ public static string GLOCK_REAR_SIGHT = "66eeef3b2a166b73d2066a79";
+ public static string GLOCK_FRONT_SIGHT = "66eeef3b2a166b73d2066a7a";
+ public static string GLOCK_MAGAZINE = "66eeef3b2a166b73d2066a7b";
+ }
+}
diff --git a/ModExamples/13.1AddTraderWithDynamicAssorts/FluentTraderAssortCreator.cs b/ModExamples/13.1AddTraderWithDynamicAssorts/FluentTraderAssortCreator.cs
new file mode 100644
index 00000000..88766830
--- /dev/null
+++ b/ModExamples/13.1AddTraderWithDynamicAssorts/FluentTraderAssortCreator.cs
@@ -0,0 +1,172 @@
+using Core.Models.Eft.Common.Tables;
+using Core.Models.Utils;
+using Core.Utils;
+
+namespace _13._1AddTraderWithDynamicAssorts;
+
+public class FluentTraderAssortCreator
+{
+ private readonly ISptLogger _logger;
+ private readonly HashUtil _hashUtil;
+
+ private readonly List
- _itemsToSell = [];
+ private readonly Dictionary>> _barterScheme = new();
+ private readonly Dictionary _loyaltyLevel = new();
+
+ public FluentTraderAssortCreator(
+ ISptLogger logger,
+ HashUtil hashUtil)
+ {
+ _logger = logger;
+ _hashUtil = hashUtil;
+ }
+
+ public FluentTraderAssortCreator CreateSingleAssortItem(string itemTpl, string? itemId = null)
+ {
+ // Create item ready for insertion into assort table
+ var newItemToAdd = new Item
+ {
+ Id = itemId ?? _hashUtil.Generate(),
+ Template = itemTpl,
+ ParentId = "hideout", // Should always be "hideout"
+ SlotId = "hideout", // Should always be "hideout"
+ Upd = new Upd
+ {
+ UnlimitedCount = false,
+ StackObjectsCount = 100
+ }
+ };
+
+ _itemsToSell.Add(newItemToAdd);
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator CreateComplexAssortItem(List
- items)
+ {
+ items[0].ParentId = "hideout";
+ items[0].SlotId = "hideout";
+
+ items[0].Upd ??= new Upd();
+
+ items[0].Upd.UnlimitedCount = false;
+ items[0].Upd.StackObjectsCount = 100;
+
+ _itemsToSell.AddRange(items);
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator AddStackCount(int stackCount)
+ {
+ _itemsToSell[0].Upd.StackObjectsCount = stackCount;
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator AddUnlimitedStackCount()
+ {
+ _itemsToSell[0].Upd.StackObjectsCount = 999999;
+ _itemsToSell[0].Upd.UnlimitedCount = true;
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator MakeStackCountUnlimited()
+ {
+ _itemsToSell[0].Upd.StackObjectsCount = 999999;
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator AddBuyRestriction(int maxBuyLimit)
+ {
+ _itemsToSell[0].Upd.BuyRestrictionMax = maxBuyLimit;
+ _itemsToSell[0].Upd.BuyRestrictionCurrent = 0;
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator AddLoyaltyLevel(int level)
+ {
+ _loyaltyLevel[_itemsToSell[0].Id] = level;
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator AddMoneyCost(string currencyType, int amount)
+ {
+ var dataToAdd = new BarterScheme
+ {
+ Count = amount,
+ Template = currencyType
+ };
+
+ _barterScheme.Add(_itemsToSell[0].Id, [[dataToAdd]]);
+
+ return this;
+ }
+
+ public FluentTraderAssortCreator AddBarterCost(string itemTpl, int count)
+ {
+ var sellableItemId = _itemsToSell[0].Id;
+
+ // No data at all, create
+ if (_barterScheme.Count == 0)
+ {
+ var dataToAdd = new BarterScheme
+ {
+ Count = count,
+ Template = itemTpl
+ };
+
+ _barterScheme[sellableItemId] = [[dataToAdd]];
+ }
+ else
+ {
+ // Item already exists, add to
+ var existingData = _barterScheme[sellableItemId][0].FirstOrDefault(x => x.Template == itemTpl);
+ if (existingData is not null)
+ {
+ // itemtpl already a barter for item, add to count
+ existingData.Count += count;
+ }
+ else
+ {
+ // No barter for item, add it fresh
+ _barterScheme[sellableItemId][0].Add(new BarterScheme
+ {
+ Count = count,
+ Template = itemTpl
+ });
+ }
+ }
+
+ return this;
+ }
+
+ /**
+ * Reset objet ready for reuse
+ * @returns
+ */
+ public FluentTraderAssortCreator? Export(Trader data)
+ {
+ var itemBeingSoldId = _itemsToSell[0].Id;
+ if (!data.Assort.Items.Exists(x => x.Id == itemBeingSoldId))
+ {
+ _logger.Error($"Unable to add complex item with item key: {_itemsToSell[0].Id}, key already used");
+
+ return null;
+ }
+
+ data.Assort.Items.AddRange(_itemsToSell);
+ data.Assort.BarterScheme[itemBeingSoldId] = _barterScheme[itemBeingSoldId];
+ data.Assort.LoyalLevelItems[itemBeingSoldId] = _loyaltyLevel[itemBeingSoldId];
+
+ _itemsToSell.Clear();
+ _barterScheme.Clear();
+ _loyaltyLevel.Clear();
+
+ return this;
+ }
+}
diff --git a/ModExamples/13.1AddTraderWithDynamicAssorts/package.json b/ModExamples/13.1AddTraderWithDynamicAssorts/package.json
new file mode 100644
index 00000000..b49687fa
--- /dev/null
+++ b/ModExamples/13.1AddTraderWithDynamicAssorts/package.json
@@ -0,0 +1,13 @@
+{
+ "Name": "13.1AddTraderWithDynamicAssorts",
+ "Version": "1.0.0",
+ "SptVersion": "~4.0",
+ "LoadBefore": [],
+ "LoadAfter": [],
+ "IncompatibileMods": [],
+ "Url": "https://github.com/sp-tarkov/server-csharp/tree/develop/ExampleMods/Mods",
+ "IsBundleMod": false,
+ "Author": "SPT",
+ "Contributors": [],
+ "Licence": "MIT"
+}
diff --git a/ModExamples/ModExamples.sln b/ModExamples/ModExamples.sln
index 32b15f79..d7d51c3d 100644
--- a/ModExamples/ModExamples.sln
+++ b/ModExamples/ModExamples.sln
@@ -38,6 +38,7 @@ EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "18.1CustomItemServiceLootBox", "18.1CustomItemServiceLootBox\18.1CustomItemServiceLootBox.csproj", "{B870C285-B435-4C40-89C4-0220D34CB9BE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "20CustomChatBot", "20CustomChatBot\20CustomChatBot.csproj", "{32271491-8CF1-4014-9A8E-E1EA22EA4292}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "13.1AddTraderWithDynamicAssorts", "13.1AddTraderWithDynamicAssorts\13.1AddTraderWithDynamicAssorts.csproj", "{9038FA64-E484-4549-9728-C50F12BBE643}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -117,6 +118,10 @@ Global
{32271491-8CF1-4014-9A8E-E1EA22EA4292}.Debug|Any CPU.Build.0 = Debug|Any CPU
{32271491-8CF1-4014-9A8E-E1EA22EA4292}.Release|Any CPU.ActiveCfg = Release|Any CPU
{32271491-8CF1-4014-9A8E-E1EA22EA4292}.Release|Any CPU.Build.0 = Release|Any CPU
+ {9038FA64-E484-4549-9728-C50F12BBE643}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {9038FA64-E484-4549-9728-C50F12BBE643}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {9038FA64-E484-4549-9728-C50F12BBE643}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {9038FA64-E484-4549-9728-C50F12BBE643}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE