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 <drakiaxyz@noreply.dev.sp-tarkov.com>
Co-committed-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com>
This commit is contained in:
DrakiaXYZ 2024-11-02 09:37:59 +00:00 committed by chomp
parent f3e3594e1b
commit 9b17a9b350
4 changed files with 234 additions and 19 deletions

View File

@ -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 @@
]
}
]
}
}

View File

@ -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",

View File

@ -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<string, string> = {};
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<void> {
// 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;
}

View File

@ -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<void> {
try {
Container.registerTypes(container);
const childContainer = container.createChildContainer();
childContainer.register<ProductionQuestsGen>("ProductionQuestsGen", ProductionQuestsGen, {
lifecycle: Lifecycle.Singleton,
});
Container.registerListTypes(childContainer);
Container.registerPostLoadTypes(container, childContainer);
await childContainer.resolve<ProductionQuestsGen>("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();