Server/project/src/tools/ItemTplGenerator/ItemTplGenerator.ts
2024-07-23 17:30:20 +01:00

500 lines
20 KiB
TypeScript

/**
* Dynamically generate the following two files:
* - src/models/enums/ItemTpl.ts
* - src/models/enums/Weapons.ts
*
* Based on data from the assets/database folders.
*
* Usage:
* - Run this script using npm: `npm run gen:items`
*
* Notes:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The following rules are used for determining item base names:
* -- Certain items are manually overridden by itemOverrides.ts, when the names are not unique enough
* -- Random containers, built in inserts and stashes utilize the item's _name property
* -- Ammo, ammo boxes, and magazines utilize the item's English locale ShortName property
* -- All other items utilize the item's English locale Name property
* -- In the event the above rules fail, the fallback order is the Englick locale Name property, then the item's _name property
* -- Trailing and leading whitespace is stripped, special characters are removed, and spaces are replaced with underscores
* - Item caliber data is cleaned of the words "CALIBER", "PARA" and "NATO", as well as replacing "1143x23ACP" with "45ACP" for consistency
* - Damaged ammo boxes are suffixed with "_DAMAGED"
* - The parent item type prefix is grouped more than the base item list, see "getParentName" for the rules around this
* - Finalized enum names are created as a combination of the parent name, prefix, item name, and suffix
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { OnLoad } from "@spt/di/OnLoad";
import { ItemHelper } from "@spt/helpers/ItemHelper";
import { ITemplateItem } from "@spt/models/eft/common/tables/ITemplateItem";
import { BaseClasses } from "@spt/models/enums/BaseClasses";
import { ItemTpl } from "@spt/models/enums/ItemTpl";
import { Weapons } from "@spt/models/enums/Weapons";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { DatabaseServer } from "@spt/servers/DatabaseServer";
import { LocaleService } from "@spt/services/LocaleService";
import * as itemTplOverrides from "@spt/tools/ItemTplGenerator/itemOverrides";
import { inject, injectAll, injectable } from "tsyringe";
@injectable()
export class ItemTplGenerator {
private enumDir: string;
private items: Record<string, ITemplateItem>;
private itemOverrides: Record<string, string>;
private collidedEnumKeys: string[] = [];
constructor(
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("LocaleService") protected localeService: LocaleService,
@inject("PrimaryLogger") protected logger: ILogger,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@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();
}
// Figure out our source and target directories
const currentDir = path.dirname(__filename);
const projectDir = path.resolve(currentDir, "..", "..", "..");
this.enumDir = path.join(projectDir, "src", "models", "enums");
this.items = this.databaseServer.getTables().templates.items;
this.itemOverrides = itemTplOverrides.default;
// Generate an object containing all item name to ID associations
const orderedItemsObject = this.generateItemsObject();
// Log any changes to enum values, so the source can be updated as required
this.logEnumValueChanges(orderedItemsObject, "ItemTpl", ItemTpl);
const itemTplOutPath = path.join(this.enumDir, "ItemTpl.ts");
this.writeEnumsToFile(itemTplOutPath, { ItemTpl: orderedItemsObject });
// Handle the weapon type enums
const weaponsObject = this.generateWeaponsObject();
this.logEnumValueChanges(weaponsObject, "Weapons", Weapons);
const weaponTypeOutPath = path.join(this.enumDir, "Weapons.ts");
this.writeEnumsToFile(weaponTypeOutPath, { Weapons: weaponsObject });
this.logger.info("Generating items finished");
}
/**
* Return an object containing all items in the game with a generated name
* @returns An object containing a generated item name to item ID association
*/
private generateItemsObject(): Record<string, string> {
const itemsObject = {};
for (const item of Object.values(this.items)) {
// Skip invalid items (Non-Item types, and shrapnel)
if (!this.isValidItem(item)) continue;
const itemParentName = this.getParentName(item);
const itemPrefix = this.getItemPrefix(item);
let itemName = this.getItemName(item);
const itemSuffix = this.getItemSuffix(item);
// Handle the case where the item starts with the parent category name. Avoids things like 'POCKETS_POCKETS'
if (itemParentName === itemName.substring(1, itemParentName.length + 1) && itemPrefix === "") {
itemName = itemName.substring(itemParentName.length + 1);
if (itemName.length > 0 && itemName.at(0) !== "_") {
itemName = `_${itemName}`;
}
}
// Handle the case where the item ends with the parent category name. Avoids things like 'KEY_DORM_ROOM_103_KEY'
if (itemParentName === itemName.substring(itemName.length - itemParentName.length)) {
itemName = itemName.substring(0, itemName.length - itemParentName.length);
if (itemName.substring(itemName.length - 1) === "_") {
itemName = itemName.substring(0, itemName.length - 1);
}
}
let itemKey = `${itemParentName}${itemPrefix}${itemName}${itemSuffix}`;
// Strip out any remaining special characters
itemKey = this.sanitizeEnumKey(itemKey);
// If the key already exists, see if we can add a suffix to both this, and the existing conflicting item
if (Object.keys(itemsObject).includes(itemKey) || this.collidedEnumKeys.includes(itemKey)) {
// Keep track, so we can handle 3+ conflicts
this.collidedEnumKeys.push(itemKey);
const itemNameSuffix = this.getItemNameSuffix(item);
if (itemNameSuffix) {
// Try to update the old key reference if we haven't already
if (itemsObject[itemKey]) {
const oldItemId = itemsObject[itemKey];
const oldItemNameSuffix = this.getItemNameSuffix(this.items[oldItemId]);
if (oldItemNameSuffix) {
const oldItemNewKey = this.sanitizeEnumKey(`${itemKey}_${oldItemNameSuffix}`);
delete itemsObject[itemKey];
itemsObject[oldItemNewKey] = oldItemId;
}
}
itemKey = this.sanitizeEnumKey(`${itemKey}_${itemNameSuffix}`);
// If we still collide, log an error
if (Object.keys(itemsObject).includes(itemKey)) {
this.logger.error(
`After rename, itemsObject already contains ${itemKey} ${itemsObject[itemKey]} => ${item._id}`,
);
}
} else {
this.logger.error(
`New itemOverride entry required: itemsObject already contains ${itemKey} ${itemsObject[itemKey]} => ${item._id}`,
);
continue;
}
}
itemsObject[itemKey] = item._id;
}
// Sort the items object
const orderedItemsObject = Object.keys(itemsObject)
.sort()
.reduce((obj, key) => {
obj[key] = itemsObject[key];
return obj;
}, {});
return orderedItemsObject;
}
/**
*
* @param orderedItemsObject The previously generated object of item name to item ID associations
* @returns
*/
private generateWeaponsObject(): Record<string, string> {
const weaponsObject = {};
for (const [itemId, item] of Object.entries(this.items)) {
if (!this.itemHelper.isOfBaseclass(itemId, BaseClasses.WEAPON)) {
continue;
}
const caliber = this.cleanCaliber(item._props.ammoCaliber.toUpperCase());
let weaponShortName = this.localeService.getLocaleDb()[`${itemId} ShortName`]?.toUpperCase();
// Special case for the weird duplicated grenade launcher
if (itemId === "639c3fbbd0446708ee622ee9") {
weaponShortName = "FN40GL_2";
}
// Include any bracketed suffixes that exist, handles the case of colored gun variants
const weaponFullName = this.localeService.getLocaleDb()[`${itemId} Name`]?.toUpperCase();
const itemNameBracketSuffix = weaponFullName.match(/\((.+?)\)$/);
if (itemNameBracketSuffix && !weaponShortName.endsWith(itemNameBracketSuffix[1])) {
weaponShortName += `_${itemNameBracketSuffix[1]}`;
}
const parentName = this.getParentName(item);
// Handle special characters
const weaponName = `${parentName}_${caliber}_${weaponShortName}`
.replace(/[- ]/g, "_")
.replace(/[^a-zA-Z0-9_]/g, "")
.toUpperCase();
if (weaponsObject[weaponName]) {
this.logger.error(`Error, weapon ${weaponName} already exists`);
continue;
}
weaponsObject[weaponName] = itemId;
}
// Sort the weapons object
const orderedWeaponsObject = Object.keys(weaponsObject)
.sort()
.reduce((obj, key) => {
obj[key] = weaponsObject[key];
return obj;
}, {});
return orderedWeaponsObject;
}
/**
* Clear any non-alpha numeric characters, and fix multiple underscores
* @param enumKey The enum key to sanitize
* @returns The sanitized enum key
*/
private sanitizeEnumKey(enumKey: string): string {
return enumKey
.toUpperCase()
.replace(/[^A-Z0-9_]/g, "")
.replace(/_+/g, "_");
}
private getParentName(item: ITemplateItem): string {
if (item._props.QuestItem) {
return "QUEST";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.BARTER_ITEM)) {
return "BARTER";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.THROW_WEAPON)) {
return "GRENADE";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.STIMULATOR)) {
return "STIM";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.MAGAZINE)) {
return "MAGAZINE";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.KEY_MECHANICAL)) {
return "KEY";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.MOB_CONTAINER)) {
return "SECURE";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.SIMPLE_CONTAINER)) {
return "CONTAINER";
}
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.PORTABLE_RANGE_FINDER)) {
return "RANGEFINDER";
}
// Why are flares grenade launcher...?
if (item._name.startsWith("weapon_rsp30")) {
return "FLARE";
}
// This is a special case for the signal pistol, I'm not adding it as a Grenade Launcher
if (item._id === "620109578d82e67e7911abf2") {
return "SIGNALPISTOL";
}
const parentId = item._parent;
return this.items[parentId]._name.toUpperCase();
}
private isValidItem(item: ITemplateItem): boolean {
const shrapnelId = "5943d9c186f7745a13413ac9";
if (item._type !== "Item") {
return false;
}
if (item._proto === shrapnelId) {
return false;
}
return true;
}
/**
* Generate a prefix for the passed in item
* @param item The item to generate the prefix for
* @returns The prefix of the given item
*/
private getItemPrefix(item: ITemplateItem): string {
let prefix = "";
// Prefix ammo with its caliber
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.AMMO)) {
prefix = this.getAmmoPrefix(item);
}
// Prefix ammo boxes with their caliber
else if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.AMMO_BOX)) {
prefix = this.getAmmoBoxPrefix(item);
}
// Prefix magazines with their caliber
else if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.MAGAZINE)) {
prefix = this.getMagazinePrefix(item);
}
// Make sure there's an underscore separator
if (prefix.length > 0 && prefix.at(0) !== "_") {
prefix = `_${prefix}`;
}
return prefix;
}
private getItemSuffix(item: ITemplateItem): string {
let suffix = "";
// Add mag size for magazines
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.MAGAZINE)) {
suffix = `${item._props.Cartridges[0]?._max_count?.toString()}RND`;
}
// Add pack size for ammo boxes
else if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.AMMO_BOX)) {
suffix = `${item._props.StackSlots[0]?._max_count.toString()}RND`;
}
// Add "DAMAGED" for damaged items
if (item._name.toLowerCase().includes("damaged")) {
suffix += "_DAMAGED";
}
// Make sure there's an underscore separator
if (suffix.length > 0 && suffix.at(0) !== "_") {
suffix = `_${suffix}`;
}
return suffix;
}
private getAmmoPrefix(item: ITemplateItem): string {
const prefix = item._props.Caliber.toUpperCase();
return this.cleanCaliber(prefix);
}
private cleanCaliber(ammoCaliber: string): string {
ammoCaliber = ammoCaliber.replace("CALIBER", "");
ammoCaliber = ammoCaliber.replace("PARA", "");
ammoCaliber = ammoCaliber.replace("NATO", "");
// Special case for 45ACP
ammoCaliber = ammoCaliber.replace("1143X23ACP", "45ACP");
return ammoCaliber;
}
private getAmmoBoxPrefix(item: ITemplateItem): string {
const ammoItem = item._props.StackSlots[0]?._props.filters[0].Filter[0];
return this.getAmmoPrefix(this.items[ammoItem]);
}
private getMagazinePrefix(item: ITemplateItem): string {
const ammoItem = item._props.Cartridges[0]?._props.filters[0].Filter[0];
return this.getAmmoPrefix(this.items[ammoItem]);
}
/**
* Return the name of the passed in item, formatted for use in an enum
* @param item The item to generate the name for
* @returns The name of the given item
*/
private getItemName(item) {
let itemName: string;
// Manual item name overrides
if (this.itemOverrides[item._id]) {
itemName = this.itemOverrides[item._id].toUpperCase();
}
// For the listed types, user the item's _name property
else if (
this.itemHelper.isOfBaseclasses(item._id, [
BaseClasses.RANDOM_LOOT_CONTAINER,
BaseClasses.BUILT_IN_INSERTS,
BaseClasses.STASH,
])
) {
itemName = item._name.toUpperCase();
}
// For the listed types, use the short name
else if (
this.itemHelper.isOfBaseclasses(item._id, [BaseClasses.AMMO, BaseClasses.AMMO_BOX, BaseClasses.MAGAZINE])
) {
itemName = this.localeService.getLocaleDb()[`${item._id} ShortName`]?.toUpperCase();
}
// For everything else, use the full name
else {
itemName = this.localeService.getLocaleDb()[`${item._id} Name`]?.toUpperCase();
}
// Fall back in the event we couldn't find a name
if (!itemName) {
itemName = this.localeService.getLocaleDb()[`${item._id} Name`]?.toUpperCase();
}
if (!itemName) {
itemName = item._name.toUpperCase();
}
if (!itemName) {
console.log(`Unable to get shortname for ${item._id}`);
return "";
}
itemName = itemName.trim().replace(/[-.()]/g, "");
itemName = itemName.replace(/[ ]/g, "_");
return `_${itemName}`;
}
private getItemNameSuffix(item: ITemplateItem): string {
const itemName = this.localeService.getLocaleDb()[`${item._id} Name`];
// Add grid size for lootable containers
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.LOOT_CONTAINER)) {
return `${item._props.Grids[0]?._props.cellsH}X${item._props.Grids[0]?._props.cellsV}`;
}
// Add ammo caliber to conflicting weapons
if (this.itemHelper.isOfBaseclass(item._id, BaseClasses.WEAPON)) {
const caliber = this.cleanCaliber(item._props.ammoCaliber.toUpperCase());
// If the item has a bracketed section at the end of its name, include that
const itemNameBracketSuffix = itemName?.match(/\((.+?)\)$/);
if (itemNameBracketSuffix) {
return `${caliber}_${itemNameBracketSuffix[1]}`;
}
return caliber;
}
// Make sure we have a full name
if (!itemName) {
return "";
}
// If the item has a bracketed section at the end of its name, use that
const itemNameBracketSuffix = itemName.match(/\((.+?)\)$/);
if (itemNameBracketSuffix) {
return itemNameBracketSuffix[1];
}
// If the item has a number at the end of its name, use that
const itemNameNumberSuffix = itemName.match(/#([0-9]+)$/);
if (itemNameNumberSuffix) {
return itemNameNumberSuffix[1];
}
return "";
}
private logEnumValueChanges(data: Record<string, string>, enumName: string, originalEnum: Record<string, string>) {
// First generate a mapping of the original enum values to names
const originalEnumValues = {};
for (const [key, value] of Object.entries(originalEnum)) {
originalEnumValues[value as string] = key;
}
// Loop through our new data, and find any where the given ID's name doesn't match the original enum
for (const [dataKey, dataValue] of Object.entries(data)) {
if (originalEnumValues[dataValue] && originalEnumValues[dataValue] !== dataKey) {
this.logger.warning(
`Enum ${enumName} key has changed for ${dataValue}, ${originalEnumValues[dataValue]} => ${dataKey}`,
);
}
}
}
private writeEnumsToFile(outputPath: string, enumEntries: Record<string, Record<string, string>>): void {
let enumFileData = "// This is an auto generated file, do not modify. Re-generate with `npm run gen:items`";
for (const [enumName, data] of Object.entries(enumEntries)) {
enumFileData += `\nexport enum ${enumName}\n{\n`;
for (const [key, value] of Object.entries(data)) {
enumFileData += ` ${key} = "${value}",\n`;
}
enumFileData += "}\n";
}
fs.writeFileSync(outputPath, enumFileData, "utf-8");
}
}