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();