From 834a2e3ef58e7e2878666be2e51c272f800425d0 Mon Sep 17 00:00:00 2001 From: Refringe Date: Wed, 10 Apr 2024 09:48:28 +0000 Subject: [PATCH] Expanded give command logic (!283) This change was originally made in master branch, between the time v3.7.6 and v3.8.0 were released. Due to the way that v3.8.0 was merged into master, and the fact that this change was never merged into v3.8.0, it had to be cherry-picked and have some conflicts resolved. I gave it my best and I would love some help testing it before it's merged for v3.8.1. Conflicts: - project/package.json - project/src/helpers/Dialogue/Commando/SptCommands/GiveSptCommand.ts Resolved by Refringe Original PR: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/182 Original Commit: 9846adc68b39924c59bb35273deb88426bac4618 Original Message: - 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 Co-authored-by: Alex Co-authored-by: chomp Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/283 Co-authored-by: Refringe Co-committed-by: Refringe --- project/package.json | 1 + project/src/di/Container.ts | 2 +- .../Dialogue/AbstractDialogueChatBot.ts | 75 +++++++ .../{ICommandoCommand.ts => IChatCommand.ts} | 7 +- .../Dialogue/Commando/SptCommandoCommands.ts | 6 +- .../Commando/SptCommands/GiveSptCommand.ts | 195 ++++++++++++++---- .../Commando/SptCommands/SavedCommand.ts | 6 + .../Dialogue/CommandoDialogueChatBot.ts | 58 +----- 8 files changed, 259 insertions(+), 91 deletions(-) create mode 100644 project/src/helpers/Dialogue/AbstractDialogueChatBot.ts rename project/src/helpers/Dialogue/Commando/{ICommandoCommand.ts => IChatCommand.ts} (66%) create mode 100644 project/src/helpers/Dialogue/Commando/SptCommands/SavedCommand.ts 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.`; } }