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 <clodan@clodan.com>
Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/182
Co-authored-by: Alex <clodan@noreply.dev.sp-tarkov.com>
Co-committed-by: Alex <clodan@noreply.dev.sp-tarkov.com>
This commit is contained in:
Alex 2023-12-31 14:18:35 +00:00 committed by chomp
parent 2e993c2687
commit 9846adc68b
8 changed files with 242 additions and 95 deletions

View File

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

View File

@ -591,7 +591,7 @@ export class Container
lifecycle: Lifecycle.Singleton,
});
// SptCommands
depContainer.register<GiveSptCommand>("GiveSptCommand", GiveSptCommand);
depContainer.register<GiveSptCommand>("GiveSptCommand", GiveSptCommand, { lifecycle: Lifecycle.Singleton });
}
private static registerLoaders(depContainer: DependencyContainer): void

View File

@ -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(),
);
}
}

View File

@ -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;

View File

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

View File

@ -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])
const result = GiveSptCommand.commandRegex.exec(request.text);
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)
{
if (this.savedCommand === undefined)
{
this.mailSendService.sendUserMessageToPlayer(
sessionId,
commandHandler,
"Invalid use of give command! Quantity is missing. Use \"Help\" for more info",
"Invalid use of give command! Use \"Help\" for more info",
);
return request.dialogId;
}
const quantity = giveCommand[3];
if (Number.isNaN(+quantity))
if (+result[6] > this.savedCommand.potentialItemNames.length)
{
this.mailSendService.sendUserMessageToPlayer(
sessionId,
commandHandler,
"Invalid use of give command! Quantity is not a valid integer. Use \"Help\" for more info",
"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,13 +216,22 @@ export class GiveSptCommand implements ISptCommand
const item: Item = {
_id: this.hashUtil.generate(),
_tpl: checkedItem[1]._id,
upd: {
StackObjectsCount: +quantity,
SpawnedInSession: true
},
upd: { StackObjectsCount: +quantity },
};
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);
return request.dialogId;

View File

@ -0,0 +1,6 @@
export class SavedCommand
{
public constructor(public quantity: number, public potentialItemNames: string[], public locale: string)
{
}
}

View File

@ -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.`;
}
}