From 9846adc68b39924c59bb35273deb88426bac4618 Mon Sep 17 00:00:00 2001 From: Alex Date: Sun, 31 Dec 2023 14:18:35 +0000 Subject: [PATCH] Expanded give command logic (!182) * Added give by name * Refactored Commando so its abstracted, that way modders can use it too! :) Co-authored-by: clodan Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/182 Co-authored-by: Alex Co-committed-by: Alex --- project/package.json | 1 + project/src/di/Container.ts | 2 +- .../Dialogue/AbstractDialogueChatBot.ts | 75 +++++++ .../{ICommandoCommand.ts => IChatCommand.ts} | 6 +- .../Dialogue/Commando/SptCommandoCommands.ts | 4 +- .../Commando/SptCommands/GiveSptCommand.ts | 185 ++++++++++++++---- .../Commando/SptCommands/SavedCommand.ts | 6 + .../Dialogue/CommandoDialogueChatBot.ts | 58 +----- 8 files changed, 242 insertions(+), 95 deletions(-) create mode 100644 project/src/helpers/Dialogue/AbstractDialogueChatBot.ts rename project/src/helpers/Dialogue/Commando/{ICommandoCommand.ts => IChatCommand.ts} (76%) create mode 100644 project/src/helpers/Dialogue/Commando/SptCommands/SavedCommand.ts diff --git a/project/package.json b/project/package.json index ce389743..130a9b43 100644 --- a/project/package.json +++ b/project/package.json @@ -27,6 +27,7 @@ }, "dependencies": { "atomically": "1.7.0", + "closest-match": "1.3.3", "i18n": "0.15.1", "json-fixer": "1.6.15", "json5": "2.2.3", diff --git a/project/src/di/Container.ts b/project/src/di/Container.ts index 990d1d39..ff7e6034 100644 --- a/project/src/di/Container.ts +++ b/project/src/di/Container.ts @@ -591,7 +591,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..ba49ee77 --- /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, + // We are keeping the alias for a few versions so modders can update in case they were using them + protected chatCommands: IChatCommand[] | ICommandoCommand[], + ) + { + } + + /** + * @deprecated use registerChatCommand instead + */ + 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 commando command ${chatCommand.getCommandPrefix()} being 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) => + `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(), + this.getUnrecognizedCommandMessage(), + ); + } +} diff --git a/project/src/helpers/Dialogue/Commando/ICommandoCommand.ts b/project/src/helpers/Dialogue/Commando/IChatCommand.ts similarity index 76% rename from project/src/helpers/Dialogue/Commando/ICommandoCommand.ts rename to project/src/helpers/Dialogue/Commando/IChatCommand.ts index 03083f3b..68abbb7e 100644 --- a/project/src/helpers/Dialogue/Commando/ICommandoCommand.ts +++ b/project/src/helpers/Dialogue/Commando/IChatCommand.ts @@ -1,7 +1,11 @@ import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; -export interface ICommandoCommand +/** + * @deprecated Use IChatCommand instead + */ +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..65e2fb19 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, diff --git a/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts b/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts index 6c4cf12c..525d387d 100644 --- a/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts +++ b/project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts @@ -10,10 +10,27 @@ import { MailSendService } from "@spt-aki/services/MailSendService"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { inject, injectable } from "tsyringe"; +import { LocaleService } from "@spt-aki/services/LocaleService"; +import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; +import { closestMatch, distance } from "closest-match"; +import { SavedCommand } from "@spt-aki/helpers/Dialogue/Commando/SptCommands/SavedCommand"; @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 isnt 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,129 @@ export class GiveSptCommand implements ISptCommand public getCommandHelp(): string { - return "Usage: spt give tplId quantity"; + return "Usage:\n\t- spt give tplId quantity\n\t- spt give locale \"item name\" quantity\n\t- spt give \"item name\" quantity\nIf using name, must be as seen in the wiki."; } 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 info", ); 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 info", + ); + return request.dialogId; + } + if (+result[6] > this.savedCommand.potentialItemNames.length) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "Invalid item selected, outside of bounds! Use \"Help\" for more info", + ); + 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, + `Invalid use of give command! Unknown locale "${locale}". Use "Help" for more info`, + ); + 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, + "We couldnt find any items that are similar to what you entered.", + ); + 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(iname => `\t${i++}. ${iname}`).join("\n"); + this.savedCommand = new SavedCommand(quantity, slicedItems, locale); + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + `We couldnt find the exact name match you were looking for. The closest matches are:\n${itemList}\nType in "spt give number" to indicate which one you want.`, + ); + return request.dialogId; + } + else + { + const dist = distance(item, closestItemsMatchedByName[0]); + if (dist > GiveSptCommand.maxAllowedDistance) + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + `There was only one match for your item search of "${item}" but its outside the acceptable bounds: ${closestItemsMatchedByName[0]}`, + ); + 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 tpl id + 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,7 +181,7 @@ 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.", + "Invalid template ID requested for give command. The item doesnt exists on the DB.", ); return request.dialogId; } @@ -100,14 +199,7 @@ export class GiveSptCommand implements ISptCommand ); return request.dialogId; } - - for (let i = 0; i < +quantity; i++) - { - // Make sure IDs are unique before adding to array - prevent collisions - const presetToSend = this.itemHelper.replaceIDs(null, this.jsonUtil.clone(preset._items)); - itemsToSend.push(... presetToSend); - } - + itemsToSend.push(...this.jsonUtil.clone(preset._items)); } else if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.AMMO_BOX)) { @@ -124,12 +216,21 @@ export class GiveSptCommand implements ISptCommand const item: Item = { _id: this.hashUtil.generate(), _tpl: checkedItem[1]._id, - upd: { - StackObjectsCount: +quantity, - SpawnedInSession: true - }, + upd: { StackObjectsCount: +quantity }, }; - itemsToSend.push(...this.itemHelper.splitStack(item)); + try + { + itemsToSend.push(...this.itemHelper.splitStack(item)); + } + catch + { + this.mailSendService.sendUserMessageToPlayer( + sessionId, + commandHandler, + "The amount of items you requested to be given caused an error, try using a smaller amount!", + ); + return request.dialogId; + } } this.mailSendService.sendSystemMessageToPlayer(sessionId, "Give command!", itemsToSend); 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 e73cda1c..b4898e96 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 { 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"; +import { AbstractDialogueChatBot } from "@spt-aki/helpers/Dialogue/AbstractDialogueChatBot"; @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 @@ -38,37 +27,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 `Im sorry soldier, I dont recognize the command you are trying to use! Type "help" to see available commands.`; } }