feat(scorpion)

This commit is contained in:
GetParanoid 2025-07-18 17:22:43 -05:00
parent 8827f6a6b0
commit 38316d15a5
23 changed files with 52336 additions and 0 deletions

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 acidphantasm
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,19 @@
{
"ThisIsAnExample": [
"Replace_This_With_Modded_Weapon_TPL_ID",
"Comma_Separate_Each_Weapon__TPL_ID",
"MODDED_WEAPON_ID_EXAMPLE_TPL",
"MODDED_WEAPON_ID_EXAMPLE_TPL2"
],
"AssaultRifles" : [],
"SubmachineGuns" : [],
"Snipers" : [],
"Marksman" : [],
"Shotguns" : [],
"Pistols" : [],
"LargeMachineGuns" : [],
"Carbines" : [],
"Melee" : [],
"Explosives" : []
}

View file

@ -0,0 +1,20 @@
{
"priceMultiplier": 1.25, // Multiplies the prices by the number set here.
"traderRefreshMin": 1800, // Minimum Refresh Timer for the trader (in seconds)
"traderRefreshMax": 3600, // Maximum Refresh Timer for the trader (in seconds)
"addTraderToFlea": true, // Enable trader to have offers on flea
"unlimitedStock": false, // Whether you want unlimited available stock - this overrides randomizeStockAvailable
"unlimitedBuyRestriction": false, // Whether you want unlimited item buys - this overrides randomizeBuyRestriction
"removeLoyaltyRestriction": false, // Removes Quest Unlocks & Loyalty Level Requirements - Entire assort available at level 1
"randomizeBuyRestriction": false, // Randomizes the amount of items in stock that you can buy per reset
"randomizeStockAvailable": false, // Randomizes the amount of items in stock per reset
"outOfStockChance": 25, // Chance for an item to be out of stock per reset
"eventQuestsAlwaysActive" : false, // Change to true to make event quests always active
"debugLogging": false // Enables server console logging for above settings
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
{
"_id": "6688d464bc40c867f60e7d7e",
"working": true,
"availableInRaid": false,
"items_buy": {
"category": [
"5422acb9af1c889c16000029",
"5485a8684bdc2da71d8b4567",
"543be6564bdc2df4348b4568",
"543be5664bdc2dd4348b4569",
"5448e54d4bdc2dcc718b4568",
"5448fe124bdc2da5018b4567",
"5448e5284bdc2dcb718b4567",
"5448e53e4bdc2d60728b4567",
"543be5f84bdc2dd4348b456a",
"543be6674bdc2df1348b4569",
"5448e8d04bdc2ddf718b4569",
"5448e8d64bdc2dce718b4568"
],
"id_list": []
},
"items_buy_prohibited": {
"category": [],
"id_list": [
"62e910aaf957f2915e0a5e36",
"64d0b40fbe2eed70e254e2d4"
]
},
"customization_seller": false,
"name": "Scorpion",
"surname": "",
"nickname": "Scorpion",
"location": "Federal State Reserve Agency Base",
"avatar": "/files/trader/avatar/6688d464bc40c867f60e7d7e.jpg",
"balance_rub": 5000000,
"balance_dol": 23000,
"balance_eur": 20000,
"unlockedByDefault": true,
"discount": 0,
"discount_end": 0,
"buyer_up": true,
"currency": "RUB",
"nextResupply": 1615141448,
"repair": {
"availability": true,
"quality": "0.35",
"excluded_id_list": [],
"excluded_category": [
"5448e54d4bdc2dcc718b4568",
"57bef4c42459772e8d35a53b",
"543be5f84bdc2dd4348b456a"
],
"currency": "5449016a4bdc2d6f028b456f",
"currency_coefficient": 1,
"price_rate": 0
},
"insurance": {
"availability": false,
"min_payment": 0,
"min_return_hour": 0,
"max_return_hour": 0,
"max_storage_time": 99,
"excluded_category": []
},
"gridHeight": 150,
"loyaltyLevels": [{
"minLevel": 1,
"minSalesSum": 0,
"minStanding": 0.00,
"buy_price_coef": 54,
"repair_price_coef": 500,
"insurance_price_coef": 1,
"exchange_price_coef": 0,
"heal_price_coef": 0
},
{
"minLevel": 15,
"minSalesSum": 500000,
"minStanding": 0.30,
"buy_price_coef": 50,
"repair_price_coef": 480,
"insurance_price_coef": 1,
"exchange_price_coef": 0,
"heal_price_coef": 0
},
{
"minLevel": 27,
"minSalesSum": 1500000,
"minStanding": 0.50,
"buy_price_coef": 45,
"repair_price_coef": 430,
"insurance_price_coef": 1,
"exchange_price_coef": 0,
"heal_price_coef": 0
},
{
"minLevel": 40,
"minSalesSum": 3000000,
"minStanding": 0.90,
"buy_price_coef": 39,
"repair_price_coef": 400,
"insurance_price_coef": 1,
"exchange_price_coef": 0,
"heal_price_coef": 0
}
],
"sell_category": [
"82e7fac0b7495d72d4083356",
"ac705d3440c1407645e33579",
"dc97aee367144dc03389405d",
"7ffcc96aa06c7e90940330c5",
"e8f46e3ad74b9d862121f9dc",
"5b47574386f77428ca22b33e",
"5b47574386f77428ca22b33f",
"5b5f78dc86f77409407a7f8e",
"5b47574386f77428ca22b346",
"5b47574386f77428ca22b340",
"5b47574386f77428ca22b344",
"5b47574386f77428ca22b342",
"5b47574386f77428ca22b341",
"5b47574386f77428ca22b345",
"5b47574386f77428ca22b343",
"5b5f71b386f774093f2ecf11",
"5b5f71c186f77409407a7ec0",
"5b5f71de86f774093f2ecf13",
"5b5f724186f77447ed5636ad",
"5b5f736886f774094242f193",
"5b5f73ec86f774093e6cb4fd",
"5b5f74cc86f77447ec5d770a",
"5b5f750686f774093e6cb503",
"5b5f751486f77447ec5d770c",
"5b5f752e86f774093e6cb505",
"5b5f754a86f774094242f19b",
"5b5f755f86f77447ec5d770e",
"5b5f757486f774093e6cb507",
"5b5f75b986f77447ec5d7710",
"5b5f75c686f774094242f19f",
"5b5f75e486f77447ec5d7712",
"5b5f760586f774093e6cb509",
"5b5f761f86f774094242f1a1",
"5b5f764186f77447ec5d7714"
]
}

View file

@ -0,0 +1,85 @@
[
{
"_id": "6635a60afe3ddd40c508c666",
"areaType": 11,
"requirements": [
{
"areaType": 11,
"requiredLevel": 1,
"type": "Area"
},
{
"templateId": "590c392f86f77444754deb29",
"count": 3,
"isFunctional": false,
"isEncoded": false,
"type": "Item"
},
{
"templateId": "590a3b0486f7743954552bdb",
"count": 10,
"isFunctional": false,
"isEncoded": false,
"type": "Item"
},
{
"questId": "66885a3d0221bbe5f306b6df",
"type": "QuestComplete"
}
],
"productionTime": 86400,
"needFuelForAllProductionTime": false,
"locked": true,
"endProduct": "5c05300686f7746dce784e5d",
"continuous": false,
"count": 1,
"productionLimitCount": 0,
"isEncoded": false,
"isCodeProduction": false
},
{
"_id": "6635a60afe3ddd40c508c667",
"areaType": 11,
"requirements": [
{
"areaType": 11,
"requiredLevel": 1,
"type": "Area"
},
{
"templateId": "590a3b0486f7743954552bdb",
"count": 10,
"isFunctional": false,
"isEncoded": false,
"type": "Item"
},
{
"templateId": "5c05308086f7746b2101e90b",
"count": 3,
"isFunctional": false,
"isEncoded": false,
"type": "Item"
},
{
"templateId": "61bf7c024770ee6f9c6b8b53",
"count": 3,
"isFunctional": false,
"isEncoded": false,
"type": "Item"
},
{
"questId": "66885a3d0221bbe5f306b6e9",
"type": "QuestComplete"
}
],
"productionTime": 86400,
"needFuelForAllProductionTime": false,
"locked": true,
"endProduct": "5c0530ee86f774697952d952",
"continuous": false,
"count": 1,
"productionLimitCount": 0,
"isEncoded": false,
"isCodeProduction": false
}
]

View file

@ -0,0 +1,52 @@
{
"started": {
"661f33776b5151c024011d07": "668858720221bbe5f306b331"
},
"success": {
"661f33776b5151c024011d02": "668858720221bbe5f306b2ea",
"661f33776b5151c024011d03": "668858720221bbe5f306b2f7",
"661f33776b5151c024011d06": "668858720221bbe5f306b30c",
"6635a60afe3ddd40c508c545": "668858720221bbe5f306b331",
"662344f0ca75908fcd0f97d2": "668858720221bbe5f306b352",
"662344feca75908fcd0f981c": "668858720221bbe5f306b35f",
"662344feca75908fcd0f982a": "668858720221bbe5f306b35f",
"6635a60afe3ddd40c508c544": "668858720221bbe5f306b35f",
"6635a60afe3ddd40c508c548": "668858720221bbe5f306b388",
"6635a60afe3ddd40c508c549": "668858720221bbe5f306b393",
"6635a60afe3ddd40c508c567": "668858720221bbe5f306b393",
"661f33776b5151c024011d04": "668858720221bbe5f306b3a4",
"661f33776b5151c024011d08": "668858720221bbe5f306b3d1",
"661f33776b5151c024011d09": "668858720221bbe5f306b3e4",
"662344f0ca75908fcd0f97ce": "668858720221bbe5f306b403",
"662344f0ca75908fcd0f97cf": "668858720221bbe5f306b414",
"662344f0ca75908fcd0f97d0": "668858720221bbe5f306b414",
"662344feca75908fcd0f981a": "668858720221bbe5f306b43c",
"6635a60afe3ddd40c508c572": "668858720221bbe5f306b534",
"6635a60afe3ddd40c508c586": "668858720221bbe5f306b598",
"6635a60afe3ddd40c508c587": "668858720221bbe5f306b5a0",
"6635a60afe3ddd40c508c588": "668858720221bbe5f306b5ac",
"6635a60afe3ddd40c508c589": "668858720221bbe5f306b5b9",
"6635a60afe3ddd40c508c5b8": "668858720221bbe5f306b607",
"6635a60afe3ddd40c508c5d3": "66885a3d0221bbe5f306b664",
"662344feca75908fcd0f985c": "66885a3d0221bbe5f306b711",
"6635a60afe3ddd40c508c58c": "66885a3d0221bbe5f306b732",
"6635a60afe3ddd40c508c53b": "66885a3d0221bbe5f306b767",
"6635a60afe3ddd40c508c58d": "66885a3d0221bbe5f306b788",
"6635a60afe3ddd40c508c4d0": "66885a3d0221bbe5f306b7be",
"6635a60afe3ddd40c508c58e": "66885a3d0221bbe5f306b7df",
"6635a60afe3ddd40c508c4f2": "66885a3d0221bbe5f306b814",
"6635a60afe3ddd40c508c58f": "66885a3d0221bbe5f306b835",
"6635a60afe3ddd40c508c53c": "66885a3d0221bbe5f306b86b",
"6635a60afe3ddd40c508c590": "66885a3d0221bbe5f306b88c",
"6635a60afe3ddd40c508c53d": "66885a3d0221bbe5f306b8c1",
"6635a60afe3ddd40c508c591": "66885a3d0221bbe5f306b8e2",
"6635a60afe3ddd40c508c53e": "66885a3d0221bbe5f306b916",
"6635a60afe3ddd40c508c592": "66885a3d0221bbe5f306b937",
"6635a60afe3ddd40c508c53f": "66885a3d0221bbe5f306b96c",
"6635a60afe3ddd40c508c593": "66885a3d0221bbe5f306b98d",
"6635a60afe3ddd40c508c5f7": "66885a3d0221bbe5f306b9cf",
"6635a60afe3ddd40c508c5f8": "66885a3d0221bbe5f306b9cf",
"6635a60afe3ddd40c508c5f9": "66885a3d0221bbe5f306b9cf"
},
"fail": {}
}

View file

@ -0,0 +1,34 @@
{
"name": "Scorpion",
"version": "0.13.1",
"main": "src/mod.js",
"license": "MIT",
"author": "acidphantasm",
"sptVersion": "~3.11",
"loadBefore": [
"zzDrakiaXYZ-LiveFleaPrices"
],
"loadAfter": [
"Virtual's Custom Quest Loader"
],
"incompatibilities": [],
"contributors": [],
"scripts": {
"setup": "npm i",
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "22.10.5",
"@typescript-eslint/eslint-plugin": "7.2",
"@typescript-eslint/parser": "7.2",
"archiver": "^6.0",
"eslint": "8.57",
"fs-extra": "11.2",
"ignore": "^5.2",
"tsyringe": "4.8.0",
"typescript": "5.7.3",
"winston": "3.17.0",
"jsonc": "2.0.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,609 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable @typescript-eslint/brace-style */
/*
* If you are reading this, I hope you are enjoying Scorpion
*
*
* I have worked on this mod for several months and have tried my best to make it as easy to read and clean as possible
* I may not always do things in the best way, but I do try!
* If you have any questions please reach out to me in the SPT Discord - do not DM me
*
*/
import { DependencyContainer, container } from "tsyringe";
// SPT types
import { IPreSptLoadMod } from "@spt/models/external/IPreSptLoadMod";
import { IPostDBLoadMod } from "@spt/models/external/IPostDBLoadMod";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { PreSptModLoader } from "@spt/loaders/PreSptModLoader";
import { DatabaseService } from "@spt/services/DatabaseService";
import { ImageRouter } from "@spt/routers/ImageRouter";
import { ConfigServer } from "@spt/servers/ConfigServer";
import { ConfigTypes } from "@spt/models/enums/ConfigTypes";
import { ITraderConfig } from "@spt/models/spt/config/ITraderConfig";
import { IRagfairConfig } from "@spt/models/spt/config/IRagfairConfig";
import type {DynamicRouterModService} from "@spt/services/mod/dynamicRouter/DynamicRouterModService";
import { RandomUtil } from "@spt/utils/RandomUtil";
// JSON Imports
import { JsonUtil } from "@spt/utils/JsonUtil";
import { FileSystemSync } from "@spt/utils/FileSystemSync";
import { jsonc } from "jsonc";
import fs from "node:fs";
import path from "node:path";
// Custom Imports
import { TraderHelper } from "./traderHelpers";
import { Traders } from "@spt/models/enums/Traders";
import baseJson = require("../db/base.json");
import questJson = require("../db/questassort.json");
import assortJson = require("../db/assort.json");
import productionJson = require("../db/production.json");
import weaponCompatibility = require("../config/ModdedWeaponCompatibility.json");
import scorpionQuests = require("../../Virtual's Custom Quest Loader/database/quests/Scorpion_quests.json");
import { RagfairOfferGenerator } from "@spt/generators/RagfairOfferGenerator";
let realismDetected: boolean;
const loadMessage = {
0: "Scorpion has brought his crew into Tarkov",
1: "One of us..one of us..one of us",
2: "Welcome to the team, you're one of us meow ♡",
3: "Call Kenny Loggins because you're in the danger zone",
4: "Can I offer you a nice egg in this trying time?",
5: "Good news everyone! We have over 100 quests!",
6: "Never half-ass two things. Whole-ass one thing.",
7: "Thanks for signing up for Cat Facts! You will now receive fun daily facts about CATS!",
8: "Thanks for signing up for Dog Facts! You will now receive fun daily facts about DOGS!",
9: "A big ball of wibbly wobbly, timey wimey stuff",
10: "(╯°□°)╯︵ ┻━┻ ",
11: "┬─┬ノ( º _ ºノ)",
12: "Treat others how you want to be treated",
13: "No act of kindness, no matter how small, is ever wasted",
14: "Reticulating Splines...",
15: "Unfolding Foldy Chairs...",
16: "Pressurizing Fruit Punch Barrel Hydraulics...",
17: "Fabricating Imaginary Infrastructure...",
18: "We apologize again for the fault in the subtitles. Those responsible for sacking the people who have just been sacked, have been sacked.",
19: "Are you suggesting coconuts migrate?",
20: "We are now the knights who say ekki-ekki-ekki-pitang-zoom-boing!",
21: "Knight jumps queen! Bishop jumps queen! Pawns jump queen!",
22: "Hello. My name is Inigo Montoya. You killed my father. Prepare to die.",
23: "I spent the last few years building up an immunity to iocane powder.",
24: "Rodents Of Unusual Size? I don't think they exist.",
25: "Always try to be nice, but never fail to be kind",
26: "Never be cruel, never be cowardly",
27: "Who do I need to ban? (◣_◢)",
28: "This loading message is sponsored by Raid: Shadow Legends"
}
class Scorpion implements IPreSptLoadMod, IPostDBLoadMod
{
private mod: string
private logger: ILogger
private traderHelper: TraderHelper
private static fileSystemSync = container.resolve<FileSystemSync>("FileSystemSync");
private static config: Config = jsonc.parse(Scorpion.fileSystemSync.read(path.resolve(__dirname, "../config/config.jsonc")));
// Set the name of mod for logging purposes
constructor()
{
this.mod = "acidphantasm-scorpion";
}
/*
* Some work needs to be done prior to SPT code being loaded
*
* TLDR:
* Resolve SPT Types
* Set trader refresh, config, image, flea settings
* Register Dynamic Router for Randomization Config
*
*/
public preSptLoad(container: DependencyContainer): void
{
// Get a logger
this.logger = container.resolve<ILogger>("WinstonLogger");
// Get SPT code/data we need later
const dynamicRouterModService = container.resolve<DynamicRouterModService>("DynamicRouterModService");
const preSptModLoader: PreSptModLoader = container.resolve<PreSptModLoader>("PreSptModLoader");
const databaseService: DatabaseService = container.resolve<DatabaseService>("DatabaseService");
const ragfairOfferGenerator = container.resolve<RagfairOfferGenerator>("RagfairOfferGenerator");
const imageRouter: ImageRouter = container.resolve<ImageRouter>("ImageRouter");
const configServer = container.resolve<ConfigServer>("ConfigServer");
const traderConfig: ITraderConfig = configServer.getConfig<ITraderConfig>(ConfigTypes.TRADER);
const ragfairConfig = configServer.getConfig<IRagfairConfig>(ConfigTypes.RAGFAIR);
// Set config values to local variables for validation & use
let minRefresh = Scorpion.config.traderRefreshMin;
let maxRefresh = Scorpion.config.traderRefreshMax;
const addToFlea = Scorpion.config.addTraderToFlea;
if (minRefresh >= maxRefresh || maxRefresh <= 2)
{
minRefresh = 1800;
maxRefresh = 3600;
this.logger.error(`[${this.mod}] [Config Issue] Refresh timers have been reset to default.`);
}
// Create helper class and use it to register our traders image/icon + set its stock refresh time
this.traderHelper = new TraderHelper();
const currentDate = new Date();
const month = currentDate.getMonth();
const day = currentDate.getDate();
if (month == 3 && day == 1)
{
baseJson.nickname = "ScorpionXYZ"
baseJson.name = "ScorpionXYZ"
baseJson.avatar = "/files/trader/avatar/6688d464bc40c867f60e7d7e_aprilfools.jpg"
this.traderHelper.registerProfileImage(baseJson, this.mod, preSptModLoader, imageRouter, "6688d464bc40c867f60e7d7e_aprilfools.jpg");
}
else this.traderHelper.registerProfileImage(baseJson, this.mod, preSptModLoader, imageRouter, "6688d464bc40c867f60e7d7e.jpg");
this.traderHelper.setTraderUpdateTime(traderConfig, baseJson, minRefresh, maxRefresh);
// Add trader to trader enum
Traders[baseJson._id] = baseJson._id;
// Add trader to flea market
if (addToFlea)
{
ragfairConfig.traders[baseJson._id] = true;
}
else
{
ragfairConfig.traders[baseJson._id] = false;
}
dynamicRouterModService.registerDynamicRouter(
"ScorpionRefreshStock",
[
{
url: "/client/items/prices/6688d464bc40c867f60e7d7e",
action: async (url, info, sessionId, output) =>
{
const trader = databaseService.getTables().traders["6688d464bc40c867f60e7d7e"];
const assortItems = trader.assort.items;
let updateFleaOffers = false;
if (!realismDetected)
{
if (Scorpion.config.randomizeBuyRestriction)
{
if (Scorpion.config.debugLogging) {this.logger.info(`[${this.mod}] Refreshing Scorpion Stock with Randomized Buy Restrictions.`);}
updateFleaOffers = true;
this.randomizeBuyRestriction(assortItems);
}
if (Scorpion.config.randomizeStockAvailable)
{
if (Scorpion.config.debugLogging) {this.logger.info(`[${this.mod}] Refreshing Scorpion Stock with Randomized Stock Availability.`);}
updateFleaOffers = true;
this.randomizeStockAvailable(assortItems);
}
if (updateFleaOffers) ragfairOfferGenerator.generateFleaOffersForTrader("6688d464bc40c867f60e7d7e");
}
return output;
}
}
],
"spt"
);
}
/*
* Some work needs to be done after loading SPT code
*
* TLDR:
* Resolve SPT Types
* Add Modded Weapons to Quests
* Mod Detection to enable/disable Assort Configuration options
* Apply Assort Configurations
* Add trader to dictionary, locales, and assort
*
*/
public postDBLoad(container: DependencyContainer): void
{
const start = performance.now();
// Resolve SPT classes we'll use
const databaseService: DatabaseService = container.resolve<DatabaseService>("DatabaseService");
const jsonUtil: JsonUtil = container.resolve<JsonUtil>("JsonUtil");
const logger = container.resolve<ILogger>("WinstonLogger");
const quests = databaseService.getTables().templates.quests;
//Set local variables for assortJson
const assortPriceTable = assortJson.barter_scheme;
const assortItemTable = assortJson.items;
const assortLoyaltyTable = assortJson.loyal_level_items;
//Run Modded Weapon Compatibility
this.moddedWeaponCompatibility();
//Enable event quests
if (Scorpion.config.eventQuestsAlwaysActive) { this.eventQuestsAlwaysActive(quests,scorpionQuests);}
//Check Mod Compatibility
this.modDetection();
//Push Production Schemes
this.pushProductionUnlocks();
//Update Assort
if (Scorpion.config.priceMultiplier !== 1) {this.setPriceMultiplier(assortPriceTable);}
if (Scorpion.config.randomizeBuyRestriction) {this.randomizeBuyRestriction(assortItemTable);}
if (Scorpion.config.randomizeStockAvailable) {this.randomizeStockAvailable(assortItemTable);}
if (Scorpion.config.unlimitedStock) {this.setUnlimitedStock(assortItemTable);}
if (Scorpion.config.unlimitedBuyRestriction) {this.setUnlimitedBuyRestriction(assortItemTable);}
if (Scorpion.config.removeLoyaltyRestriction) {this.disableLoyaltyRestrictions(assortLoyaltyTable);}
// Set local variable for assort to pass to traderHelper regardless of priceMultiplier config
const newAssort = assortJson
// Get a reference to the database tables
const tables = databaseService.getTables();
// Add new trader to the trader dictionary in DatabaseServer
// Add quest assort
// Add trader to locale file, ensures trader text shows properly on screen
this.traderHelper.addTraderToDb(baseJson, tables, jsonUtil, newAssort);
tables.traders[baseJson._id].questassort = questJson;
this.traderHelper.addTraderToLocales(baseJson, tables, baseJson.name, baseJson._id, baseJson.nickname, baseJson.location, "I'm sellin', what are you buyin'?");
this.logger.debug(`[${this.mod}] loaded... `);
const timeTaken = performance.now() - start;
if (Scorpion.config.debugLogging) {logger.log(`[${this.mod}] Trader load took ${timeTaken.toFixed(3)}ms.`, "cyan");}
logger.log(`[${this.mod}] ${this.getRandomLoadMessage()}`, "cyan");
}
/*
*
* All functions are below this comment
*
* Most of these functions should be self explanatory
*
*/
private setRealismDetection(i: boolean) // Except this one. This is dumb. I'll fix it eventually.
{
realismDetected = i;
if (realismDetected && Scorpion.config.randomizeBuyRestriction || realismDetected && Scorpion.config.randomizeStockAvailable)
{
this.logger.log(`[${this.mod}] SPT-Realism detected, disabling randomizeBuyRestriction and/or randomizeStockAvailable:`, "cyan");
}
}
private setPriceMultiplier (assortPriceTable)
{
let priceMultiplier = Scorpion.config.priceMultiplier;
if (priceMultiplier <= 0)
{
priceMultiplier = 1;
this.logger.error(`[${this.mod}] priceMultiplier cannot be set to zero.`)
}
for (const itemID in assortPriceTable)
{
for (const item of assortPriceTable[itemID])
{
if (item[0].count <= 15)
{
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] itemID: [${itemID}] No price change, it's a barter trade.`, "cyan");}
continue;
}
const count = item[0].count;
const newPrice = Math.round(count * priceMultiplier);
item[0].count = newPrice
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] itemID: [${itemID}] Price Changed to: [${newPrice}]`, "cyan");}
}
}
}
private randomizeBuyRestriction(assortItemTable)
{
const randomUtil: RandomUtil = container.resolve<RandomUtil>("RandomUtil");
if (!realismDetected) // If realism is not detected, continue, else do nothing
{
for (const item in assortItemTable)
{
if (assortItemTable[item].parentId !== "hideout" || assortItemTable[item].upd.BuyRestrictionMax <= 3)
{
continue // Skip setting count, it's a weapon attachment or armour plate
}
const itemID = assortItemTable[item]._id;
const oldRestriction = assortItemTable[item].upd.BuyRestrictionMax;
const newRestriction = randomUtil.randInt(4, oldRestriction + 40);
assortItemTable[item].upd.BuyRestrictionMax = newRestriction;
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] Item: [${itemID}] Buy Restriction Changed to: [${newRestriction}]`, "cyan");}
}
}
}
private randomizeStockAvailable(assortItemTable)
{
const randomUtil: RandomUtil = container.resolve<RandomUtil>("RandomUtil");
if (!realismDetected) // If realism is not detected, continue, else do nothing
{
for (const item in assortItemTable)
{
if (assortItemTable[item].parentId !== "hideout")
{
continue // Skip setting count, it's a weapon attachment or armour plate
}
const outOfStockRoll = randomUtil.getChance100(Scorpion.config.outOfStockChance);
if (outOfStockRoll)
{
const itemID = assortItemTable[item]._id;
assortItemTable[item].upd.StackObjectsCount = 0;
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] Item: [${itemID}] Marked out of stock`, "cyan");}
}
else
{
if (assortItemTable[item].upd.StackObjectsCount <= 10) assortItemTable[item].upd.StackObjectsCount = 250
const itemID = assortItemTable[item]._id;
const originalStock = assortItemTable[item].upd.StackObjectsCount;
const newStock = randomUtil.randInt(3, Math.round(originalStock*0.75));
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] Item: [${itemID}] Original Count: ${originalStock} | Stock Count changed to: [${newStock}]`, "cyan");}
assortItemTable[item].upd.StackObjectsCount = newStock;
}
}
}
}
private setUnlimitedStock(assortItemTable)
{
for (const item in assortItemTable)
{
if (assortItemTable[item].parentId !== "hideout")
{
continue // Skip setting count, it's a weapon attachment or armour plate
}
assortItemTable[item].upd.StackObjectsCount = 9999999;
assortItemTable[item].upd.UnlimitedCount = true;
}
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] Item stock counts are now unlimited`, "cyan");}
}
private setUnlimitedBuyRestriction(assortItemTable)
{
for (const item in assortItemTable)
{
if (assortItemTable[item].parentId !== "hideout")
{
continue // Skip setting count, it's a weapon attachment or armour plate
}
delete assortItemTable[item].upd.BuyRestrictionMax;
delete assortItemTable[item].upd.BuyRestrictionCurrent;
}
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] Item buy restrictions are now unlimited`, "cyan");}
}
private disableLoyaltyRestrictions(assortLoyaltyTable)
{
for (const item in assortLoyaltyTable)
{
delete assortLoyaltyTable[item];
}
if (Scorpion.config.debugLogging) {this.logger.log(`[${this.mod}] All Loyalty Level requirements are removed`, "cyan");}
}
private modDetection()
{
const preSptModLoader: PreSptModLoader = container.resolve<PreSptModLoader>("PreSptModLoader");
const vcqlCheck = preSptModLoader.getImportedModsNames().includes("Virtual's Custom Quest Loader");
const realismCheck = preSptModLoader.getImportedModsNames().includes("SPT-Realism");
const vcqlDllPath = path.resolve(__dirname, "../../../../BepInEx/plugins/VCQLQuestZones.dll");
const heliCrashSamSWAT = path.resolve(__dirname, "../../../../BepInEx/plugins/SamSWAT.HeliCrash/SamSWAT.HeliCrash.dll");
const heliCrashTyrian = path.resolve(__dirname, "../../../../BepInEx/plugins/SamSWAT.HeliCrash.TyrianReboot/SamSWAT.HeliCrash.TyrianReboot.dll");
const heliCrashArys = path.resolve(__dirname, "../../../../BepInEx/plugins/SamSWAT.HeliCrash.ArysReloaded/SamSWAT.HeliCrash.ArysReloaded.dll");
// VCQL Zones DLL is missing
if (!fs.existsSync(vcqlDllPath))
{
this.logger.error(`[${this.mod}] VCQL Zones DLL missing. Custom Trader quests may not work properly.`);
}
// Outdated HeliCrash is installed
if (fs.existsSync(heliCrashSamSWAT) || fs.existsSync(heliCrashTyrian))
{
this.logger.error(`[${this.mod}] Outdated HeliCrash Mod Detected. You will experience issues with Custom Trader quest zones.`);
}
// Arys HeliCrash is installed
if (fs.existsSync(heliCrashArys))
{
this.logger.warning(`[${this.mod}] HeliCrash Mod Detected. You may experience issues with Custom Trader quest zones.`);
}
// VCQL package.json is missing
if (!vcqlCheck)
{
this.logger.error(`[${this.mod}] VCQL not detected. Install VCQL and re-install ${this.mod}.`);
}
// This is completely unneccessary and I'll fix it, eventually - probably
if (Scorpion.config.randomizeBuyRestriction || Scorpion.config.randomizeStockAvailable)
{
this.setRealismDetection(realismCheck);
}
else
{
this.setRealismDetection(realismCheck);
}
}
private moddedWeaponCompatibility()
{
const databaseService: DatabaseService = container.resolve<DatabaseService>("DatabaseService");
const questTable = databaseService.getTables().templates.quests;
const quests = Object.values(questTable);
let questType;
let weaponType;
let wasAdded:boolean;
if (weaponCompatibility.AssaultRifles.length >= 1)
{
weaponType = weaponCompatibility.AssaultRifles;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - ARs"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.SubmachineGuns.length >= 1)
{
weaponType = weaponCompatibility.SubmachineGuns;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - SMGs"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Snipers.length >= 1)
{
weaponType = weaponCompatibility.Snipers;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Snipers"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Marksman.length >= 1)
{
weaponType = weaponCompatibility.Marksman;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Marksman"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Shotguns.length >= 1)
{
weaponType = weaponCompatibility.Shotguns;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Shotguns"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Pistols.length >= 1)
{
weaponType = weaponCompatibility.Pistols;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Pistols"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.LargeMachineGuns.length >= 1)
{
weaponType = weaponCompatibility.LargeMachineGuns;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - LMGs"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Carbines.length >= 1)
{
weaponType = weaponCompatibility.Carbines;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Carbines"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Melee.length >= 1)
{
weaponType = weaponCompatibility.Melee;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Melee"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (weaponCompatibility.Explosives.length >= 1)
{
weaponType = weaponCompatibility.Explosives;
questType = quests.filter(x => x.QuestName.includes("Weapon Proficiency - Explosives"));
wasAdded = true;
this.moddedWeaponPushToArray(questType, weaponType);
}
if (wasAdded) { this.logger.log(`[${this.mod}] Custom Weapons added to proficiency quests. Enjoy!`, "cyan"); }
}
private moddedWeaponPushToArray(questTable, weaponType)
{
for (const quest in questTable)
{
for (const condition in questTable[quest].conditions.AvailableForFinish)
{
for (const item in questTable[quest].conditions.AvailableForFinish[condition].counter.conditions)
{
for (const id of weaponType)
{
questTable[quest].conditions.AvailableForFinish[condition].counter.conditions[item].weapon.push(id);
}
}
}
if (Scorpion.config.debugLogging) { this.logger.log(`[${this.mod}] ${questTable[quest].QuestName} --- Added ${weaponType}`, "cyan"); }
}
}
private eventQuestsAlwaysActive(questTable, quests)
{
let eventCount = 0;
for (const quest in quests)
{
if (quests[quest]?.startMonth)
{
const currentDate = new Date();
const questStartDate = new Date(currentDate.getFullYear(), quests[quest].startMonth - 1, quests[quest].startDay)
const questEndDate = new Date(currentDate.getFullYear(), quests[quest].endMonth - 1, quests[quest].endDay)
if (currentDate < questStartDate || currentDate > questEndDate)
{
delete quests[quest].startMonth;
delete quests[quest].endMonth;
delete quests[quest].startDay;
delete quests[quest].endDay
questTable[quest] = quests[quest];
eventCount++;
}
}
}
this.logger.log(`[${this.mod}] Reactivated ${eventCount} Event Quests from Scorpion - Enjoy!`, "cyan");
this.logger.log(`[${this.mod}] !!! Remember to fix your config.jsonc when you update this mod to keep event quest progress !!!`, "cyan");
}
private pushProductionUnlocks() {
const databaseService: DatabaseService = container.resolve<DatabaseService>("DatabaseService");
const recipesTable = databaseService.getTables().hideout.production.recipes;
for (const item of productionJson)
{
recipesTable.push(item);
}
}
private getRandomLoadMessage()
{
const value = loadMessage[Math.floor(Math.random() * Object.keys(loadMessage).length)];
return value;
}
}
/*
*
* This is the interface for the config to validate the values
*
*/
interface Config
{
randomizeStockAvailable: boolean,
outOfStockChance: number,
randomizeBuyRestriction: boolean,
priceMultiplier: number,
unlimitedStock: boolean,
unlimitedBuyRestriction: boolean,
removeLoyaltyRestriction: boolean,
traderRefreshMin: number,
traderRefreshMax: number,
addTraderToFlea: boolean,
eventQuestsAlwaysActive: boolean,
debugLogging: boolean,
}
module.exports = { mod: new Scorpion() }

View file

@ -0,0 +1,106 @@
import { PreSptModLoader } from "@spt/loaders/PreSptModLoader";
import { ITraderBase, ITraderAssort } from "@spt/models/eft/common/tables/ITrader";
import { ITraderConfig, UpdateTime } from "@spt/models/spt/config/ITraderConfig";
import { IDatabaseTables } from "@spt/models/spt/server/IDatabaseTables";
import { ImageRouter } from "@spt/routers/ImageRouter";
import { JsonUtil } from "@spt/utils/JsonUtil";
import { ITraderUnlockRequirement } from "@spt/models/eft/hideout/IQteData";
import * as questAssort from "../db/questassort.json";
export class TraderHelper
{
/**
* Add profile picture to our trader
* @param baseJson json file for trader (db/base.json)
* @param preSptModLoader mod loader class - used to get the mods file path
* @param imageRouter image router class - used to register the trader image path so we see their image on trader page
* @param traderImageName Filename of the trader icon to use
*/
public registerProfileImage(baseJson: any, modName: string, preSptModLoader: PreSptModLoader, imageRouter: ImageRouter, traderImageName: string): void
{
// Reference the mod "res" folder
const imageFilepath = `./${preSptModLoader.getModPath(modName)}res`;
// Register a route to point to the profile picture - remember to remove the .jpg from it
imageRouter.addRoute(baseJson.avatar.replace(".jpg", ""), `${imageFilepath}/${traderImageName}`);
}
/**
* 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 setTraderUpdateTime(traderConfig: ITraderConfig, baseJson: any, refreshTimeSecondsMin: number, refreshTimeSecondsMax: number): void
{
// Add refresh time in seconds to config
const traderRefreshRecord: UpdateTime = {
traderId: baseJson._id,
seconds: {
min: refreshTimeSecondsMin,
max: refreshTimeSecondsMax
} };
traderConfig.updateTime.push(traderRefreshRecord);
}
/**
* Add our new trader to the database
* @param traderDetailsToAdd trader details
* @param tables database
* @param jsonUtil json utility class
*/
// rome-ignore lint/suspicious/noExplicitAny: traderDetailsToAdd comes from base.json, so no type
public addTraderToDb(traderDetailsToAdd: any, tables: IDatabaseTables, jsonUtil: JsonUtil, newAssort: any): void
{
// Add trader to trader table, key is the traders id
tables.traders[traderDetailsToAdd._id] = {
assort: jsonUtil.deserialize(jsonUtil.serialize(newAssort)) as ITraderAssort, // assorts are the 'offers' trader sells, can be a single item (e.g. carton of milk) or multiple items as a collection (e.g. a gun)
base: jsonUtil.deserialize(jsonUtil.serialize(traderDetailsToAdd)) as ITraderBase, // Deserialise/serialise creates a copy of the json and allows us to cast it as an ITraderBase
questassort: jsonUtil.deserialize(jsonUtil.serialize(questAssort)) // questassort is empty as trader has no assorts unlocked by quests
};
}
/**
* Create basic data for trader + add empty assorts table for trader
* @param tables SPT db
* @param jsonUtil SPT JSON utility class
* @returns ITraderAssort
*/
private createAssortTable(): ITraderAssort
{
// Create a blank assort object, ready to have items added
const assortTable: ITraderAssort = {
nextResupply: 0,
items: [],
barter_scheme: {},
loyal_level_items: {}
}
return assortTable;
}
/**
* 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 addTraderToLocales(baseJson: any, tables: IDatabaseTables, fullName: string, firstName: string, nickName: string, location: string, description: string)
{
// For each language, add locale for the new trader
const locales = Object.values(tables.locales.global) as Record<string, string>[];
for (const locale of locales) {
locale[`${baseJson._id} FullName`] = fullName;
locale[`${baseJson._id} FirstName`] = firstName;
locale[`${baseJson._id} Nickname`] = nickName;
locale[`${baseJson._id} Location`] = location;
locale[`${baseJson._id} Description`] = description;
}
}
}