diff --git a/project/package.json b/project/package.json index eaeae684..30963880 100644 --- a/project/package.json +++ b/project/package.json @@ -33,6 +33,7 @@ "dependencies": { "atomically": "~1.7", "buffer-crc32": "^1.0.0", + "closest-match": "~1.3", "date-fns": "~2.30", "date-fns-tz": "~2.0", "i18n": "~0.15", diff --git a/project/src/di/Container.ts b/project/src/di/Container.ts index 30ab1fb1..f9012381 100644 --- a/project/src/di/Container.ts +++ b/project/src/di/Container.ts @@ -596,7 +596,7 @@ export class Container lifecycle: Lifecycle.Singleton, }); // SptCommands - depContainer.register("GiveSptCommand", GiveSptCommand); + depContainer.register("GiveSptCommand", GiveSptCommand, { lifecycle: Lifecycle.Singleton }); } private static registerLoaders(depContainer: DependencyContainer): void diff --git a/project/src/helpers/Dialogue/AbstractDialogueChatBot.ts b/project/src/helpers/Dialogue/AbstractDialogueChatBot.ts new file mode 100644 index 00000000..ed387b97 --- /dev/null +++ b/project/src/helpers/Dialogue/AbstractDialogueChatBot.ts @@ -0,0 +1,75 @@ +import { IChatCommand, ICommandoCommand } from "@spt-aki/helpers/Dialogue/Commando/IChatCommand"; +import { IDialogueChatBot } from "@spt-aki/helpers/Dialogue/IDialogueChatBot"; +import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; +import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; +import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; +import { MailSendService } from "@spt-aki/services/MailSendService"; + +export abstract class AbstractDialogueChatBot implements IDialogueChatBot +{ + public constructor( + protected logger: ILogger, + protected mailSendService: MailSendService, + protected chatCommands: IChatCommand[] | ICommandoCommand[], + ) + { + } + + /** + * @deprecated As of v3.7.6. Use registerChatCommand. + */ + // TODO: v3.9.0 - Remove registerCommandoCommand method. + public registerCommandoCommand(chatCommand: IChatCommand | ICommandoCommand): void + { + this.registerChatCommand(chatCommand); + } + + public registerChatCommand(chatCommand: IChatCommand | ICommandoCommand): void + { + if (this.chatCommands.some((cc) => cc.getCommandPrefix() === chatCommand.getCommandPrefix())) + { + throw new Error( + `The command "${chatCommand.getCommandPrefix()}" attempting to be registered already exists.`, + ); + } + this.chatCommands.push(chatCommand); + } + + public abstract getChatBot(): IUserDialogInfo; + + protected abstract getUnrecognizedCommandMessage(): string; + + public handleMessage(sessionId: string, request: ISendMessageRequest): string + { + if ((request.text ?? "").length === 0) + { + this.logger.error("Command came in as empty text! Invalid data!"); + return request.dialogId; + } + + const splitCommand = request.text.split(" "); + + const commandos = this.chatCommands.filter((c) => c.getCommandPrefix() === splitCommand[0]); + if (commandos[0]?.getCommands().has(splitCommand[1])) + { + return commandos[0].handle(splitCommand[1], this.getChatBot(), sessionId, request); + } + + if (splitCommand[0].toLowerCase() === "help") + { + const helpMessage = this.chatCommands.map((c) => + `Available commands:\n\n${c.getCommandPrefix()}:\n\n${ + Array.from(c.getCommands()).map((command) => c.getCommandHelp(command)).join("\n") + }` + ).join("\n"); + this.mailSendService.sendUserMessageToPlayer(sessionId, this.getChatBot(), helpMessage); + return request.dialogId; + } + + this.mailSendService.sendUserMessageToPlayer( + sessionId, + this.getChatBot(), + this.getUnrecognizedCommandMessage(), + ); + } +} diff --git a/project/src/helpers/Dialogue/Commando/ICommandoCommand.ts b/project/src/helpers/Dialogue/Commando/IChatCommand.ts similarity index 66% rename from project/src/helpers/Dialogue/Commando/ICommandoCommand.ts rename to project/src/helpers/Dialogue/Commando/IChatCommand.ts index 03083f3b..93a63487 100644 --- a/project/src/helpers/Dialogue/Commando/ICommandoCommand.ts +++ b/project/src/helpers/Dialogue/Commando/IChatCommand.ts @@ -1,7 +1,12 @@ import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; -export interface ICommandoCommand +/** + * @deprecated As of v3.7.6. Use IChatCommand. Will be removed in v3.9.0. + */ +// TODO: v3.9.0 - Remove ICommandoCommand. +export type ICommandoCommand = IChatCommand; +export interface IChatCommand { getCommandPrefix(): string; getCommandHelp(command: string): string; diff --git a/project/src/helpers/Dialogue/Commando/SptCommandoCommands.ts b/project/src/helpers/Dialogue/Commando/SptCommandoCommands.ts index 82017853..1c2a6f46 100644 --- a/project/src/helpers/Dialogue/Commando/SptCommandoCommands.ts +++ b/project/src/helpers/Dialogue/Commando/SptCommandoCommands.ts @@ -1,4 +1,4 @@ -import { ICommandoCommand } from "@spt-aki/helpers/Dialogue/Commando/ICommandoCommand"; +import { IChatCommand } from "@spt-aki/helpers/Dialogue/Commando/IChatCommand"; import { ISptCommand } from "@spt-aki/helpers/Dialogue/Commando/SptCommands/ISptCommand"; import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; @@ -8,7 +8,7 @@ import { ConfigServer } from "@spt-aki/servers/ConfigServer"; import { inject, injectAll, injectable } from "tsyringe"; @injectable() -export class SptCommandoCommands implements ICommandoCommand +export class SptCommandoCommands implements IChatCommand { constructor( @inject("ConfigServer") protected configServer: ConfigServer, @@ -31,7 +31,7 @@ export class SptCommandoCommands implements ICommandoCommand { if (this.sptCommands.some((c) => c.getCommand() === command.getCommand())) { - throw new Error(`The command ${command.getCommand()} being registered for SPT Commands already exists!`); + throw new Error(`The command "${command.getCommand()}" attempting to be registered already exists.`); } this.sptCommands.push(command); } diff --git a/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts b/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts index 644c1835..ccab75e2 100644 --- a/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts +++ b/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts @@ -1,4 +1,5 @@ import { ISptCommand } from "@spt-aki/helpers/Dialogue/Commando/SptCommands/ISptCommand"; +import { SavedCommand } from "@spt-aki/helpers/Dialogue/Commando/SptCommands/SavedCommand"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; import { Item } from "@spt-aki/models/eft/common/tables/IItem"; @@ -6,14 +7,30 @@ import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequ import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; +import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; +import { LocaleService } from "@spt-aki/services/LocaleService"; import { MailSendService } from "@spt-aki/services/MailSendService"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; +import { closestMatch, distance } from "closest-match"; import { inject, injectable } from "tsyringe"; @injectable() export class GiveSptCommand implements ISptCommand { + /** + * Regex to account for all these cases: + * spt give "item name" 5 + * spt give templateId 5 + * spt give en "item name in english" 5 + * spt give es "nombre en espaƱol" 5 + * spt give 5 <== this is the reply when the algo isn't sure about an item + */ + private static commandRegex = /^spt give (((([a-z]{2,5}) )?"(.+)"|\w+) )?([0-9]+)$/; + private static maxAllowedDistance = 1.5; + + protected savedCommand: SavedCommand; + public constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("ItemHelper") protected itemHelper: ItemHelper, @@ -21,6 +38,8 @@ export class GiveSptCommand implements ISptCommand @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("PresetHelper") protected presetHelper: PresetHelper, @inject("MailSendService") protected mailSendService: MailSendService, + @inject("LocaleService") protected localeService: LocaleService, + @inject("DatabaseServer") protected databaseServer: DatabaseServer, ) { } @@ -32,49 +51,135 @@ export class GiveSptCommand implements ISptCommand public getCommandHelp(): string { - return "Usage: spt give tplId quantity"; + return "spt give\n========\nSends items to the player through the message system.\n\n\tspt give [template ID] [quantity]\n\t\tEx: spt give 544fb25a4bdc2dfb738b4567 2\n\n\tspt give [\"item name\"] [quantity]\n\t\tEx: spt give \"pack of sugar\" 10\n\n\tspt give [locale] [\"item name\"] [quantity]\n\t\tEx: spt give fr \"figurine de chat\" 3"; } public performAction(commandHandler: IUserDialogInfo, sessionId: string, request: ISendMessageRequest): string { - const giveCommand = request.text.split(" "); - if (giveCommand[1] !== "give") - { - this.logger.error("Invalid action received for give command!"); - return request.dialogId; - } - - if (!giveCommand[2]) + if (!GiveSptCommand.commandRegex.test(request.text)) { this.mailSendService.sendUserMessageToPlayer( sessionId, commandHandler, - "Invalid use of give command! Template ID is missing. Use \"Help\" for more info", + "Invalid use of give command. Use \"help\" for more information.", ); return request.dialogId; } - const tplId = giveCommand[2]; - if (!giveCommand[3]) - { - this.mailSendService.sendUserMessageToPlayer( - sessionId, - commandHandler, - "Invalid use of give command! Quantity is missing. Use \"Help\" for more info", - ); - return request.dialogId; - } - const quantity = giveCommand[3]; + const result = GiveSptCommand.commandRegex.exec(request.text); - if (Number.isNaN(+quantity)) + let item: string; + let quantity: number; + let isItemName: boolean; + let locale: string; + + // This is a reply to a give request previously made pending a reply + if (result[1] === undefined) { - this.mailSendService.sendUserMessageToPlayer( - sessionId, - commandHandler, - "Invalid use of give command! Quantity is not a valid integer. Use \"Help\" for more info", - ); - return request.dialogId; + if (this.savedCommand === undefined) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "Invalid use of give command. Use \"help\" for more information.", + ); + return request.dialogId; + } + if (+result[6] > this.savedCommand.potentialItemNames.length) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "Invalid selection. Outside of bounds! Use \"help\" for more information.", + ); + return request.dialogId; + } + item = this.savedCommand.potentialItemNames[+result[6] - 1]; + quantity = this.savedCommand.quantity; + locale = this.savedCommand.locale; + isItemName = true; + this.savedCommand = undefined; } + else + { + // A new give request was entered, we need to ignore the old saved command + this.savedCommand = undefined; + isItemName = result[5] !== undefined; + item = result[5] ? result[5] : result[2]; + quantity = +result[6]; + + if (isItemName) + { + locale = result[4] ? result[4] : this.localeService.getDesiredGameLocale(); + if (!this.localeService.getServerSupportedLocales().includes(locale)) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + `Unknown locale "${locale}". Use \"help\" for more information.`, + ); + return request.dialogId; + } + + const localizedGlobal = this.databaseServer.getTables().locales.global[locale]; + + const closestItemsMatchedByName = closestMatch( + item.toLowerCase(), + this.itemHelper.getItems().filter((i) => i._type !== "Node").map((i) => + localizedGlobal[`${i?._id} Name`]?.toLowerCase() + ).filter((i) => i !== undefined), + true, + ) as string[]; + + if (closestItemsMatchedByName === undefined || closestItemsMatchedByName.length === 0) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "That item could not be found. Please refine your request and try again.", + ); + return request.dialogId; + } + + if (closestItemsMatchedByName.length > 1) + { + let i = 1; + const slicedItems = closestItemsMatchedByName.slice(0, 10); + // max 10 item names and map them + const itemList = slicedItems.map((itemName) => `${i++}. ${itemName}`).join("\n"); + this.savedCommand = new SavedCommand(quantity, slicedItems, locale); + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + `Could not find exact match. Closest matches are:\n\n${itemList}\n\nUse "spt give [number]" to select one.`, + ); + return request.dialogId; + } + + const dist = distance(item, closestItemsMatchedByName[0]); + if (dist > GiveSptCommand.maxAllowedDistance) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + `Found a possible match for "${item}" but uncertain. Match: "${ + closestItemsMatchedByName[0] + }". Please refine your request and try again.`, + ); + return request.dialogId; + } + // Only one available so we get that entry and use it + item = closestItemsMatchedByName[0]; + } + } + + // If item is an item name, we need to search using that item name and the locale which one we want otherwise + // item is just the tplId. + const tplId = isItemName + ? this.itemHelper.getItems().find((i) => + this.databaseServer.getTables().locales.global[locale][`${i?._id} Name`]?.toLowerCase() === item + )._id + : item; const checkedItem = this.itemHelper.getItem(tplId); if (!checkedItem[0]) @@ -82,21 +187,25 @@ export class GiveSptCommand implements ISptCommand this.mailSendService.sendUserMessageToPlayer( sessionId, commandHandler, - "Invalid template ID requested for give command. The item doesn't exist in the DB.", + "That item could not be found. Please refine your request and try again.", ); return request.dialogId; } const itemsToSend: Item[] = []; - const preset = this.presetHelper.getDefaultPreset(checkedItem[1]._id); - if (preset) + if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.WEAPON)) { - for (let i = 0; i < +quantity; i++) + const preset = this.presetHelper.getDefaultPreset(checkedItem[1]._id); + if (!preset) { - // Make sure IDs are unique before adding to array - prevent collisions - const presetToSend = this.itemHelper.replaceIDs(preset._items); - itemsToSend.push(...presetToSend); + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "That weapon template ID could not be found. Please refine your request and try again.", + ); + return request.dialogId; } + itemsToSend.push(...this.jsonUtil.clone(preset._items)); } else if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.AMMO_BOX)) { @@ -115,13 +224,25 @@ export class GiveSptCommand implements ISptCommand _tpl: checkedItem[1]._id, upd: { StackObjectsCount: +quantity, SpawnedInSession: true }, }; - itemsToSend.push(...this.itemHelper.splitStack(item)); + try + { + itemsToSend.push(...this.itemHelper.splitStack(item)); + } + catch + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "Too many items requested. Please lower the amount and try again.", + ); + return request.dialogId; + } } // Flag the items as FiR this.itemHelper.setFoundInRaid(itemsToSend); - this.mailSendService.sendSystemMessageToPlayer(sessionId, "Give command!", itemsToSend); + this.mailSendService.sendSystemMessageToPlayer(sessionId, "SPT GIVE", itemsToSend); return request.dialogId; } } diff --git a/project/src/helpers/Dialogue/Commando/SptCommands/SavedCommand.ts b/project/src/helpers/Dialogue/Commando/SptCommands/SavedCommand.ts new file mode 100644 index 00000000..c51412a4 --- /dev/null +++ b/project/src/helpers/Dialogue/Commando/SptCommands/SavedCommand.ts @@ -0,0 +1,6 @@ +export class SavedCommand +{ + public constructor(public quantity: number, public potentialItemNames: string[], public locale: string) + { + } +} diff --git a/project/src/helpers/Dialogue/CommandoDialogueChatBot.ts b/project/src/helpers/Dialogue/CommandoDialogueChatBot.ts index c000d186..db8018dc 100644 --- a/project/src/helpers/Dialogue/CommandoDialogueChatBot.ts +++ b/project/src/helpers/Dialogue/CommandoDialogueChatBot.ts @@ -1,33 +1,22 @@ import { inject, injectAll, injectable } from "tsyringe"; -import { ICommandoCommand } from "@spt-aki/helpers/Dialogue/Commando/ICommandoCommand"; -import { IDialogueChatBot } from "@spt-aki/helpers/Dialogue/IDialogueChatBot"; -import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; +import { AbstractDialogueChatBot } from "@spt-aki/helpers/Dialogue/AbstractDialogueChatBot"; +import { IChatCommand } from "@spt-aki/helpers/Dialogue/Commando/IChatCommand"; import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; import { MemberCategory } from "@spt-aki/models/enums/MemberCategory"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { MailSendService } from "@spt-aki/services/MailSendService"; @injectable() -export class CommandoDialogueChatBot implements IDialogueChatBot +export class CommandoDialogueChatBot extends AbstractDialogueChatBot { public constructor( - @inject("WinstonLogger") protected logger: ILogger, - @inject("MailSendService") protected mailSendService: MailSendService, - @injectAll("CommandoCommand") protected commandoCommands: ICommandoCommand[], + @inject("WinstonLogger") logger: ILogger, + @inject("MailSendService") mailSendService: MailSendService, + @injectAll("CommandoCommand") chatCommands: IChatCommand[], ) { - } - - public registerCommandoCommand(commandoCommand: ICommandoCommand): void - { - if (this.commandoCommands.some((cc) => cc.getCommandPrefix() === commandoCommand.getCommandPrefix())) - { - throw new Error( - `The commando command ${commandoCommand.getCommandPrefix()} being registered already exists!`, - ); - } - this.commandoCommands.push(commandoCommand); + super(logger, mailSendService, chatCommands); } public getChatBot(): IUserDialogInfo @@ -39,37 +28,8 @@ export class CommandoDialogueChatBot implements IDialogueChatBot }; } - public handleMessage(sessionId: string, request: ISendMessageRequest): string + protected getUnrecognizedCommandMessage(): string { - if ((request.text ?? "").length === 0) - { - this.logger.error("Commando command came in as empty text! Invalid data!"); - return request.dialogId; - } - - const splitCommand = request.text.split(" "); - - const commandos = this.commandoCommands.filter((c) => c.getCommandPrefix() === splitCommand[0]); - if (commandos[0]?.getCommands().has(splitCommand[1])) - { - return commandos[0].handle(splitCommand[1], this.getChatBot(), sessionId, request); - } - - if (splitCommand[0].toLowerCase() === "help") - { - const helpMessage = this.commandoCommands.map((c) => - `Help for ${c.getCommandPrefix()}:\n${ - Array.from(c.getCommands()).map((command) => c.getCommandHelp(command)).join("\n") - }` - ).join("\n"); - this.mailSendService.sendUserMessageToPlayer(sessionId, this.getChatBot(), helpMessage); - return request.dialogId; - } - - this.mailSendService.sendUserMessageToPlayer( - sessionId, - this.getChatBot(), - `Im sorry soldier, I dont recognize the command you are trying to use! Type "help" to see available commands.`, - ); + return `I'm sorry soldier, I don't recognize the command you are trying to use! Type "help" to see available commands.`; } }