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 <me@refringe.com>

Original PR: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/182
Original Commit: 9846adc68b

Original Message:

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

Co-authored-by: Alex <clodan@noreply.dev.sp-tarkov.com>
Co-authored-by: chomp <chomp@noreply.dev.sp-tarkov.com>
Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/283
Co-authored-by: Refringe <refringe@noreply.dev.sp-tarkov.com>
Co-committed-by: Refringe <refringe@noreply.dev.sp-tarkov.com>
This commit is contained in:
Refringe 2024-04-10 09:48:28 +00:00 committed by chomp
parent 8342edf55f
commit 834a2e3ef5
8 changed files with 259 additions and 91 deletions

View File

@ -33,6 +33,7 @@
"dependencies": { "dependencies": {
"atomically": "~1.7", "atomically": "~1.7",
"buffer-crc32": "^1.0.0", "buffer-crc32": "^1.0.0",
"closest-match": "~1.3",
"date-fns": "~2.30", "date-fns": "~2.30",
"date-fns-tz": "~2.0", "date-fns-tz": "~2.0",
"i18n": "~0.15", "i18n": "~0.15",

View File

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

View File

@ -1,7 +1,12 @@
import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest";
import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; 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; getCommandPrefix(): string;
getCommandHelp(command: string): 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 { ISptCommand } from "@spt-aki/helpers/Dialogue/Commando/SptCommands/ISptCommand";
import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest"; import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest";
import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; 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"; import { inject, injectAll, injectable } from "tsyringe";
@injectable() @injectable()
export class SptCommandoCommands implements ICommandoCommand export class SptCommandoCommands implements IChatCommand
{ {
constructor( constructor(
@inject("ConfigServer") protected configServer: ConfigServer, @inject("ConfigServer") protected configServer: ConfigServer,
@ -31,7 +31,7 @@ export class SptCommandoCommands implements ICommandoCommand
{ {
if (this.sptCommands.some((c) => c.getCommand() === command.getCommand())) 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); this.sptCommands.push(command);
} }

View File

@ -1,4 +1,5 @@
import { ISptCommand } from "@spt-aki/helpers/Dialogue/Commando/SptCommands/ISptCommand"; 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 { ItemHelper } from "@spt-aki/helpers/ItemHelper";
import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; import { PresetHelper } from "@spt-aki/helpers/PresetHelper";
import { Item } from "@spt-aki/models/eft/common/tables/IItem"; 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 { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile";
import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; 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 { MailSendService } from "@spt-aki/services/MailSendService";
import { HashUtil } from "@spt-aki/utils/HashUtil"; import { HashUtil } from "@spt-aki/utils/HashUtil";
import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { closestMatch, distance } from "closest-match";
import { inject, injectable } from "tsyringe"; import { inject, injectable } from "tsyringe";
@injectable() @injectable()
export class GiveSptCommand implements ISptCommand 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( public constructor(
@inject("WinstonLogger") protected logger: ILogger, @inject("WinstonLogger") protected logger: ILogger,
@inject("ItemHelper") protected itemHelper: ItemHelper, @inject("ItemHelper") protected itemHelper: ItemHelper,
@ -21,6 +38,8 @@ export class GiveSptCommand implements ISptCommand
@inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("PresetHelper") protected presetHelper: PresetHelper, @inject("PresetHelper") protected presetHelper: PresetHelper,
@inject("MailSendService") protected mailSendService: MailSendService, @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 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 public performAction(commandHandler: IUserDialogInfo, sessionId: string, request: ISendMessageRequest): string
{ {
const giveCommand = request.text.split(" "); if (!GiveSptCommand.commandRegex.test(request.text))
if (giveCommand[1] !== "give")
{
this.logger.error("Invalid action received for give command!");
return request.dialogId;
}
if (!giveCommand[2])
{ {
this.mailSendService.sendUserMessageToPlayer( this.mailSendService.sendUserMessageToPlayer(
sessionId, sessionId,
commandHandler, 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; return request.dialogId;
} }
const tplId = giveCommand[2];
if (!giveCommand[3]) const result = GiveSptCommand.commandRegex.exec(request.text);
{
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];
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( if (this.savedCommand === undefined)
sessionId, {
commandHandler, this.mailSendService.sendUserMessageToPlayer(
"Invalid use of give command! Quantity is not a valid integer. Use \"Help\" for more info", sessionId,
); commandHandler,
return request.dialogId; "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); const checkedItem = this.itemHelper.getItem(tplId);
if (!checkedItem[0]) if (!checkedItem[0])
@ -82,21 +187,25 @@ export class GiveSptCommand implements ISptCommand
this.mailSendService.sendUserMessageToPlayer( this.mailSendService.sendUserMessageToPlayer(
sessionId, sessionId,
commandHandler, 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; return request.dialogId;
} }
const itemsToSend: Item[] = []; const itemsToSend: Item[] = [];
const preset = this.presetHelper.getDefaultPreset(checkedItem[1]._id); if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.WEAPON))
if (preset)
{ {
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 this.mailSendService.sendUserMessageToPlayer(
const presetToSend = this.itemHelper.replaceIDs(preset._items); sessionId,
itemsToSend.push(...presetToSend); 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)) else if (this.itemHelper.isOfBaseclass(checkedItem[1]._id, BaseClasses.AMMO_BOX))
{ {
@ -115,13 +224,25 @@ export class GiveSptCommand implements ISptCommand
_tpl: checkedItem[1]._id, _tpl: checkedItem[1]._id,
upd: { StackObjectsCount: +quantity, SpawnedInSession: true }, 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 // Flag the items as FiR
this.itemHelper.setFoundInRaid(itemsToSend); this.itemHelper.setFoundInRaid(itemsToSend);
this.mailSendService.sendSystemMessageToPlayer(sessionId, "Give command!", itemsToSend); this.mailSendService.sendSystemMessageToPlayer(sessionId, "SPT GIVE", itemsToSend);
return request.dialogId; 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 { inject, injectAll, injectable } from "tsyringe";
import { ICommandoCommand } from "@spt-aki/helpers/Dialogue/Commando/ICommandoCommand"; import { AbstractDialogueChatBot } from "@spt-aki/helpers/Dialogue/AbstractDialogueChatBot";
import { IDialogueChatBot } from "@spt-aki/helpers/Dialogue/IDialogueChatBot"; import { IChatCommand } from "@spt-aki/helpers/Dialogue/Commando/IChatCommand";
import { ISendMessageRequest } from "@spt-aki/models/eft/dialog/ISendMessageRequest";
import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile"; import { IUserDialogInfo } from "@spt-aki/models/eft/profile/IAkiProfile";
import { MemberCategory } from "@spt-aki/models/enums/MemberCategory"; import { MemberCategory } from "@spt-aki/models/enums/MemberCategory";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { MailSendService } from "@spt-aki/services/MailSendService"; import { MailSendService } from "@spt-aki/services/MailSendService";
@injectable() @injectable()
export class CommandoDialogueChatBot implements IDialogueChatBot export class CommandoDialogueChatBot extends AbstractDialogueChatBot
{ {
public constructor( public constructor(
@inject("WinstonLogger") protected logger: ILogger, @inject("WinstonLogger") logger: ILogger,
@inject("MailSendService") protected mailSendService: MailSendService, @inject("MailSendService") mailSendService: MailSendService,
@injectAll("CommandoCommand") protected commandoCommands: ICommandoCommand[], @injectAll("CommandoCommand") chatCommands: IChatCommand[],
) )
{ {
} super(logger, mailSendService, chatCommands);
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);
} }
public getChatBot(): IUserDialogInfo 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) return `I'm sorry soldier, I don't recognize the command you are trying to use! Type "help" to see available commands.`;
{
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.`,
);
} }
} }