From 9b17a9b3504a014f8ec5079093f8113c82f27746 Mon Sep 17 00:00:00 2001 From: DrakiaXYZ Date: Sat, 2 Nov 2024 09:37:59 +0000 Subject: [PATCH] Create new script for associating productions with quests (!421) See `src\tools\ProductionQuestsGen\ProductionQuestsGen.ts` for usage I've run the tool and committed the updated production.json as an example of it successfully managing to find the quest association for most productions (Aside from new event quest productions) Co-authored-by: DrakiaXYZ <565558+TheDgtl@users.noreply.github.com> Reviewed-on: https://dev.sp-tarkov.com/SPT/Server/pulls/421 Co-authored-by: DrakiaXYZ Co-committed-by: DrakiaXYZ --- .../assets/database/hideout/production.json | 53 ++++-- project/package.json | 3 +- .../ProductionQuestsGen.ts | 159 ++++++++++++++++++ .../ProductionQuestsGenProgram.ts | 38 +++++ 4 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 project/src/tools/ProductionQuestsGen/ProductionQuestsGen.ts create mode 100644 project/src/tools/ProductionQuestsGen/ProductionQuestsGenProgram.ts diff --git a/project/assets/database/hideout/production.json b/project/assets/database/hideout/production.json index 93328646..df1ab60f 100644 --- a/project/assets/database/hideout/production.json +++ b/project/assets/database/hideout/production.json @@ -2088,7 +2088,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "6396701b9113f06a7c3b2379" } ] }, @@ -2122,7 +2123,8 @@ "type": "Area" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "625d700cc48e6c62a440fab5" }, { "templateId": "590c2d8786f774245b1f03f3", @@ -2317,7 +2319,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "63966fbeea19ac7ed845db2e" } ] }, @@ -2392,7 +2395,8 @@ "type": "Item" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "5a27bc8586f7741b543d8ea4" } ] }, @@ -3349,7 +3353,8 @@ "type": "Item" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "5c1141f386f77430ff393792" } ] }, @@ -3701,7 +3706,8 @@ "type": "Item" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "60e71c11d54b755a3b53eb65" } ] }, @@ -3760,7 +3766,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "60e71b62a0beca400d69efc4" } ] }, @@ -4458,7 +4465,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "5a27bb8386f7741c770d2d0a" } ] }, @@ -4499,7 +4507,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "60e71c9ad54b755a3b53eb66" } ] }, @@ -4738,7 +4747,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "625d6ffaf7308432be1d44c5" } ] }, @@ -4783,7 +4793,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "5ae4497b86f7744cf402ed00" }, { "count": 2, @@ -6378,7 +6389,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "63966faeea19ac7ed845db2c" } ] }, @@ -6430,7 +6442,8 @@ "type": "Item" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "5c0d4e61d09282029f53920e" } ] }, @@ -6500,7 +6513,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "63966fccac6f8f3c677b9d89" } ] }, @@ -6986,7 +7000,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "6396700fea19ac7ed845db32" } ] }, @@ -7058,7 +7073,8 @@ "type": "Tool" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "64e7b9bffd30422ed03dad38" } ] }, @@ -7673,7 +7689,8 @@ "type": "Item" }, { - "type": "QuestComplete" + "type": "QuestComplete", + "questId": "5ae4495086f77443c122bc40" }, { "count": 1, @@ -8283,4 +8300,4 @@ ] } ] -} +} \ No newline at end of file diff --git a/project/package.json b/project/package.json index e9437ad3..feaa7d50 100644 --- a/project/package.json +++ b/project/package.json @@ -30,7 +30,8 @@ "run:profiler": "gulp run:profiler", "gen:types": "tsc -p tsconfig.typedef.json --resolveJsonModule", "gen:docs": "typedoc --options ./typedoc.json --entryPointStrategy expand ./src", - "gen:items": "ts-node -r tsconfig-paths/register ./src/tools/ItemTplGenerator/ItemTplGeneratorProgram.ts" + "gen:items": "ts-node -r tsconfig-paths/register ./src/tools/ItemTplGenerator/ItemTplGeneratorProgram.ts", + "gen:productionquests": "ts-node -r tsconfig-paths/register ./src/tools/ProductionQuestsGen/ProductionQuestsGenProgram.ts" }, "dependencies": { "atomically": "~1.7", diff --git a/project/src/tools/ProductionQuestsGen/ProductionQuestsGen.ts b/project/src/tools/ProductionQuestsGen/ProductionQuestsGen.ts new file mode 100644 index 00000000..7b911e28 --- /dev/null +++ b/project/src/tools/ProductionQuestsGen/ProductionQuestsGen.ts @@ -0,0 +1,159 @@ +/** + * Automatically update the `questId` property for production `QuestComplete` requirements in `assets/database/hideout/production.json` + * Based on data from both `production.json` and `quests.json`. + * + * Usage: + * - Run this script using npm: `npm run gen:productionquests` + * + * Notes: + * - Some productions may output "Unable to find quest" if new quests or event quests haven't been dumped + * - Some productions may output "Quest ... is already associated" if a quest unlocks multiple assorts, this can be ignored + * - The list of "blacklistedProductions" is to stop spurious errors when we know a production is no longer necessary (Old events) + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { OnLoad } from "@spt/di/OnLoad"; +import { QuestRewardType } from "@spt/models/enums/QuestRewardType"; +import { ILogger } from "@spt/models/spt/utils/ILogger"; +import { DatabaseServer } from "@spt/servers/DatabaseServer"; +import { inject, injectAll, injectable } from "tsyringe"; + +@injectable() +export class ProductionQuestsGen { + private questProductionOutputList: QuestProductionOutput[] = []; + private questProductionMap: Record = {}; + + private blacklistedProductions = [ + "6617cdb6b24b0ea24505f618", // Old event quest production "Radio Repeater" alt recipe + "66140c4a9688754de10dac07", // Old event quest production "Documents with decrypted data" + "661e6c26750e453380391f55", // Old event quest production "Documents with decrypted data" + "660c2dbaa2a92e70cc074863", // Old event quest production "Decrypted flash drive" + ]; + + constructor( + @inject("DatabaseServer") protected databaseServer: DatabaseServer, + @inject("PrimaryLogger") protected logger: ILogger, + @injectAll("OnLoad") protected onLoadComponents: OnLoad[], + ) {} + + async run(): Promise { + // Load all of the onload components, this gives us access to most of SPTs injections + for (const onLoad of this.onLoadComponents) { + await onLoad.onLoad(); + } + + // Build up our dataset + this.buildQuestProductionList(); + this.updateProductionQuests(); + + // Dump the new data to disk + const currentDir = path.dirname(__filename); + const projectDir = path.resolve(currentDir, "..", "..", ".."); + const hideoutDir = path.join(projectDir, "assets", "database", "hideout"); + const productionOutPath = path.join(hideoutDir, "production.json"); + fs.writeFileSync( + productionOutPath, + JSON.stringify(this.databaseServer.getTables().hideout.production, null, 2), + "utf-8", + ); + } + + private updateProductionQuests(): void { + // Loop through all productions, and try to associate any with a `QuestComplete` type with its quest + for (const production of this.databaseServer.getTables().hideout.production.recipes) { + // Skip blacklisted productions + if (this.blacklistedProductions.includes(production._id)) continue; + + // If the production has no quest requirement, or more than 1, skip it + const questCompleteList = production.requirements.filter((req) => req.type === "QuestComplete"); + if (questCompleteList.length === 0) continue; + if (questCompleteList.length > 1) { + this.logger.error(`Error, production ${production._id} contains multiple QuestComplete requirements`); + continue; + } + + // Try to find the quest that matches this production + const questProductionOutputs = this.questProductionOutputList.filter( + (output) => output.ItemTemplate === production.endProduct && output.Quantity === production.count, + ); + + // Make sure we found valid data + if (!this.isValidQuestProduction(production, questProductionOutputs, questCompleteList[0])) continue; + + // Update the production quest ID + this.questProductionMap[questProductionOutputs[0].QuestId] = production._id; + questCompleteList[0].questId = questProductionOutputs[0].QuestId; + this.logger.success( + `Updated ${production._id}, ${production.endProduct} with quantity ${production.count} to target quest ${questProductionOutputs[0].QuestId}`, + ); + } + } + + private isValidQuestProduction(production, questProductionOutputs, questComplete): boolean { + // A lot of error handling for edge cases + if (questProductionOutputs.length === 0) { + this.logger.error( + `Unable to find quest for production ${production._id}, endProduct ${production.endProduct} with quantity ${production.count}. Potential new or removed quest`, + ); + return false; + } + if (questProductionOutputs.length > 1) { + this.logger.error( + `Multiple quests match production ${production._id}, endProduct ${production.endProduct} with quantity ${production.count}`, + ); + return false; + } + if (questComplete.questId && questComplete.questId !== questProductionOutputs[0].QuestId) { + this.logger.error( + `Multiple productions match quest. EndProduct ${production.endProduct} with quantity ${production.count}, existing quest ${questComplete.questId}`, + ); + return false; + } + if (this.questProductionMap[questProductionOutputs[0].QuestId]) { + this.logger.warning( + `Quest ${questProductionOutputs[0].QuestId} is already associated with production ${this.questProductionMap[questProductionOutputs[0].QuestId]}. Potential conflict`, + ); + } + + return true; + } + + // Build a list of all quests and what production they unlock + private buildQuestProductionList(): void { + for (const quest of Object.values(this.databaseServer.getTables().templates.quests)) { + for (const rewardState of Object.values(quest.rewards)) { + for (const reward of rewardState) { + if (reward.type !== QuestRewardType.PRODUCTIONS_SCHEME) continue; + + // Make the assumption all productions only output a single item template + const output: QuestProductionOutput = { + QuestId: quest._id, + ItemTemplate: reward.items[0]._tpl, + Quantity: 0, + }; + + for (const item of reward.items) { + // Skip any item that has a parent, we only care about the root item(s) + if (item.parentId) continue; + if (item._tpl !== output.ItemTemplate) { + this.logger.error( + `Production scheme has multiple output items. ${output.ItemTemplate} !== ${item._tpl}`, + ); + continue; + } + + output.Quantity += item.upd.StackObjectsCount; + } + + this.questProductionOutputList.push(output); + } + } + } + } +} + +class QuestProductionOutput { + public QuestId: string; + public ItemTemplate: string; + public Quantity: number; +} diff --git a/project/src/tools/ProductionQuestsGen/ProductionQuestsGenProgram.ts b/project/src/tools/ProductionQuestsGen/ProductionQuestsGenProgram.ts new file mode 100644 index 00000000..9fb348e4 --- /dev/null +++ b/project/src/tools/ProductionQuestsGen/ProductionQuestsGenProgram.ts @@ -0,0 +1,38 @@ +import "reflect-metadata"; +import "source-map-support/register"; + +import { ErrorHandler } from "@spt/ErrorHandler"; +import { Container } from "@spt/di/Container"; +import { ProductionQuestsGen } from "@spt/tools/ProductionQuestsGen/ProductionQuestsGen"; +import { Lifecycle, container } from "tsyringe"; + +export class ProductionQuestsGenProgram { + private errorHandler: ErrorHandler; + constructor() { + // set window properties + process.stdout.setEncoding("utf8"); + process.title = "SPT ProductionQuestsGen"; + this.errorHandler = new ErrorHandler(); + } + + public async start(): Promise { + try { + Container.registerTypes(container); + const childContainer = container.createChildContainer(); + childContainer.register("ProductionQuestsGen", ProductionQuestsGen, { + lifecycle: Lifecycle.Singleton, + }); + Container.registerListTypes(childContainer); + Container.registerPostLoadTypes(container, childContainer); + await childContainer.resolve("ProductionQuestsGen").run(); + } catch (err: any) { + this.errorHandler.handleCriticalError(err instanceof Error ? err : new Error(err)); + } + + // Kill the process, something holds it open so we need to manually kill it + process.exit(); + } +} + +const program = new ProductionQuestsGenProgram(); +program.start();