4ac12ef70a
These are the formatting & linting configuration changes from the `3.8.0` branch and the changes that they make to the overall project. The majority of these changes are from running two commands: `npm run lint:fix` `npm run style:fix` This has already been run on the `3.8.0` branch and this PR should make `master` play nicer when it comes to merges going forward. There are now four VSCode plugins recommended for server development. They've been added to the workspace file and a user should get a UI notification when the workspace is opened if they're not installed. The four plugins are: https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig https://marketplace.visualstudio.com/items?itemName=dprint.dprint https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint https://marketplace.visualstudio.com/items?itemName=biomejs.biome Once installed they should just work within the workspace. Also, be sure to `npm i` to get the new dprint application. Co-authored-by: Refringe <brownelltyler@gmail.com> Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/168
575 lines
21 KiB
TypeScript
575 lines
21 KiB
TypeScript
import { inject, injectable } from "tsyringe";
|
|
|
|
import { DialogueHelper } from "@spt-aki/helpers/DialogueHelper";
|
|
import { ItemHelper } from "@spt-aki/helpers/ItemHelper";
|
|
import { NotificationSendHelper } from "@spt-aki/helpers/NotificationSendHelper";
|
|
import { NotifierHelper } from "@spt-aki/helpers/NotifierHelper";
|
|
import { TraderHelper } from "@spt-aki/helpers/TraderHelper";
|
|
import { Item } from "@spt-aki/models/eft/common/tables/IItem";
|
|
import { Dialogue, IUserDialogInfo, Message, MessageItems } from "@spt-aki/models/eft/profile/IAkiProfile";
|
|
import { MessageType } from "@spt-aki/models/enums/MessageType";
|
|
import { Traders } from "@spt-aki/models/enums/Traders";
|
|
import { ISendMessageDetails } from "@spt-aki/models/spt/dialog/ISendMessageDetails";
|
|
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
|
|
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
|
|
import { SaveServer } from "@spt-aki/servers/SaveServer";
|
|
import { LocalisationService } from "@spt-aki/services/LocalisationService";
|
|
import { HashUtil } from "@spt-aki/utils/HashUtil";
|
|
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
|
|
|
|
@injectable()
|
|
export class MailSendService
|
|
{
|
|
protected readonly systemSenderId = "59e7125688a45068a6249071";
|
|
|
|
constructor(
|
|
@inject("WinstonLogger") protected logger: ILogger,
|
|
@inject("HashUtil") protected hashUtil: HashUtil,
|
|
@inject("TimeUtil") protected timeUtil: TimeUtil,
|
|
@inject("SaveServer") protected saveServer: SaveServer,
|
|
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
|
|
@inject("NotifierHelper") protected notifierHelper: NotifierHelper,
|
|
@inject("DialogueHelper") protected dialogueHelper: DialogueHelper,
|
|
@inject("NotificationSendHelper") protected notificationSendHelper: NotificationSendHelper,
|
|
@inject("LocalisationService") protected localisationService: LocalisationService,
|
|
@inject("ItemHelper") protected itemHelper: ItemHelper,
|
|
@inject("TraderHelper") protected traderHelper: TraderHelper,
|
|
)
|
|
{}
|
|
|
|
/**
|
|
* Send a message from an NPC (e.g. prapor) to the player with or without items using direct message text, do not look up any locale
|
|
* @param sessionId The session ID to send the message to
|
|
* @param trader The trader sending the message
|
|
* @param messageType What type the message will assume (e.g. QUEST_SUCCESS)
|
|
* @param message Text to send to the player
|
|
* @param items Optional items to send to player
|
|
* @param maxStorageTimeSeconds Optional time to collect items before they expire
|
|
*/
|
|
public sendDirectNpcMessageToPlayer(
|
|
sessionId: string,
|
|
trader: Traders,
|
|
messageType: MessageType,
|
|
message: string,
|
|
items: Item[] = [],
|
|
maxStorageTimeSeconds = null,
|
|
systemData = null,
|
|
ragfair = null,
|
|
): void
|
|
{
|
|
if (!trader)
|
|
{
|
|
this.logger.error(
|
|
this.localisationService.getText("mailsend-missing_trader", {
|
|
messageType: messageType,
|
|
sessionId: sessionId,
|
|
}),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const details: ISendMessageDetails = {
|
|
recipientId: sessionId,
|
|
sender: messageType,
|
|
dialogType: MessageType.NPC_TRADER,
|
|
trader: trader,
|
|
messageText: message,
|
|
};
|
|
|
|
// Add items to message
|
|
if (items?.length > 0)
|
|
{
|
|
details.items = items;
|
|
details.itemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds ?? 172800; // 48 hours if no value supplied
|
|
}
|
|
|
|
if (systemData)
|
|
{
|
|
details.systemData = systemData;
|
|
}
|
|
|
|
if (ragfair)
|
|
{
|
|
details.ragfairDetails = ragfair;
|
|
}
|
|
|
|
this.sendMessageToPlayer(details);
|
|
}
|
|
|
|
/**
|
|
* Send a message from an NPC (e.g. prapor) to the player with or without items
|
|
* @param sessionId The session ID to send the message to
|
|
* @param trader The trader sending the message
|
|
* @param messageType What type the message will assume (e.g. QUEST_SUCCESS)
|
|
* @param messageLocaleId The localised text to send to player
|
|
* @param items Optional items to send to player
|
|
* @param maxStorageTimeSeconds Optional time to collect items before they expire
|
|
*/
|
|
public sendLocalisedNpcMessageToPlayer(
|
|
sessionId: string,
|
|
trader: Traders,
|
|
messageType: MessageType,
|
|
messageLocaleId: string,
|
|
items: Item[] = [],
|
|
maxStorageTimeSeconds = null,
|
|
systemData = null,
|
|
ragfair = null,
|
|
): void
|
|
{
|
|
if (!trader)
|
|
{
|
|
this.logger.error(
|
|
this.localisationService.getText("mailsend-missing_trader", {
|
|
messageType: messageType,
|
|
sessionId: sessionId,
|
|
}),
|
|
);
|
|
|
|
return;
|
|
}
|
|
|
|
const details: ISendMessageDetails = {
|
|
recipientId: sessionId,
|
|
sender: messageType,
|
|
dialogType: MessageType.NPC_TRADER,
|
|
trader: trader,
|
|
templateId: messageLocaleId,
|
|
};
|
|
|
|
// Add items to message
|
|
if (items?.length > 0)
|
|
{
|
|
details.items = items;
|
|
details.itemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds ?? 172800; // 48 hours if no value supplied
|
|
}
|
|
|
|
if (systemData)
|
|
{
|
|
details.systemData = systemData;
|
|
}
|
|
|
|
if (ragfair)
|
|
{
|
|
details.ragfairDetails = ragfair;
|
|
}
|
|
|
|
this.sendMessageToPlayer(details);
|
|
}
|
|
|
|
/**
|
|
* Send a message from SYSTEM to the player with or without items
|
|
* @param sessionId The session ID to send the message to
|
|
* @param message The text to send to player
|
|
* @param items Optional items to send to player
|
|
* @param maxStorageTimeSeconds Optional time to collect items before they expire
|
|
*/
|
|
public sendSystemMessageToPlayer(
|
|
sessionId: string,
|
|
message: string,
|
|
items: Item[] = [],
|
|
maxStorageTimeSeconds = null,
|
|
): void
|
|
{
|
|
const details: ISendMessageDetails = {
|
|
recipientId: sessionId,
|
|
sender: MessageType.SYSTEM_MESSAGE,
|
|
messageText: message,
|
|
};
|
|
|
|
// Add items to message
|
|
if (items.length > 0)
|
|
{
|
|
details.items = items;
|
|
details.itemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds ?? 172800; // 48 hours if no value supplied
|
|
}
|
|
|
|
this.sendMessageToPlayer(details);
|
|
}
|
|
|
|
/**
|
|
* Send a message from SYSTEM to the player with or without items with localised text
|
|
* @param sessionId The session ID to send the message to
|
|
* @param messageLocaleId Id of key from locale file to send to player
|
|
* @param items Optional items to send to player
|
|
* @param maxStorageTimeSeconds Optional time to collect items before they expire
|
|
*/
|
|
public sendLocalisedSystemMessageToPlayer(
|
|
sessionId: string,
|
|
messageLocaleId: string,
|
|
items: Item[] = [],
|
|
maxStorageTimeSeconds = null,
|
|
): void
|
|
{
|
|
const details: ISendMessageDetails = {
|
|
recipientId: sessionId,
|
|
sender: MessageType.SYSTEM_MESSAGE,
|
|
templateId: messageLocaleId,
|
|
};
|
|
|
|
// Add items to message
|
|
if (items?.length > 0)
|
|
{
|
|
details.items = items;
|
|
details.itemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds ?? 172800; // 48 hours if no value supplied
|
|
}
|
|
|
|
this.sendMessageToPlayer(details);
|
|
}
|
|
|
|
/**
|
|
* Send a USER message to a player with or without items
|
|
* @param sessionId The session ID to send the message to
|
|
* @param senderId Who is sending the message
|
|
* @param message The text to send to player
|
|
* @param items Optional items to send to player
|
|
* @param maxStorageTimeSeconds Optional time to collect items before they expire
|
|
*/
|
|
public sendUserMessageToPlayer(
|
|
sessionId: string,
|
|
senderDetails: IUserDialogInfo,
|
|
message: string,
|
|
items: Item[] = [],
|
|
maxStorageTimeSeconds = null,
|
|
): void
|
|
{
|
|
const details: ISendMessageDetails = {
|
|
recipientId: sessionId,
|
|
sender: MessageType.USER_MESSAGE,
|
|
senderDetails: senderDetails,
|
|
messageText: message,
|
|
};
|
|
|
|
// Add items to message
|
|
if (items?.length > 0)
|
|
{
|
|
details.items = items;
|
|
details.itemsMaxStorageLifetimeSeconds = maxStorageTimeSeconds ?? 172800; // 48 hours if no value supplied
|
|
}
|
|
|
|
this.sendMessageToPlayer(details);
|
|
}
|
|
|
|
/**
|
|
* Large function to send messages to players from a variety of sources (SYSTEM/NPC/USER)
|
|
* Helper functions in this class are available to simplify common actions
|
|
* @param messageDetails Details needed to send a message to the player
|
|
*/
|
|
public sendMessageToPlayer(messageDetails: ISendMessageDetails): void
|
|
{
|
|
// Get dialog, create if doesn't exist
|
|
const senderDialog = this.getDialog(messageDetails);
|
|
|
|
// Flag dialog as containing a new message to player
|
|
senderDialog.new++;
|
|
|
|
// Craft message
|
|
const message = this.createDialogMessage(senderDialog._id, messageDetails);
|
|
|
|
// Create items array
|
|
// Generate item stash if we have rewards.
|
|
const itemsToSendToPlayer = this.processItemsBeforeAddingToMail(senderDialog.type, messageDetails);
|
|
|
|
// If there's items to send to player, flag dialog as containing attachments
|
|
if (itemsToSendToPlayer.data?.length > 0)
|
|
{
|
|
senderDialog.attachmentsNew += 1;
|
|
}
|
|
|
|
// Store reward items inside message and set appropriate flags inside message
|
|
this.addRewardItemsToMessage(message, itemsToSendToPlayer, messageDetails.itemsMaxStorageLifetimeSeconds);
|
|
|
|
// Add message to dialog
|
|
senderDialog.messages.push(message);
|
|
|
|
// TODO: clean up old code here
|
|
// Offer Sold notifications are now separate from the main notification
|
|
if (
|
|
[MessageType.NPC_TRADER, MessageType.FLEAMARKET_MESSAGE].includes(senderDialog.type)
|
|
&& messageDetails.ragfairDetails
|
|
)
|
|
{
|
|
const offerSoldMessage = this.notifierHelper.createRagfairOfferSoldNotification(
|
|
message,
|
|
messageDetails.ragfairDetails,
|
|
);
|
|
this.notificationSendHelper.sendMessage(messageDetails.recipientId, offerSoldMessage);
|
|
message.type = MessageType.MESSAGE_WITH_ITEMS; // Should prevent getting the same notification popup twice
|
|
}
|
|
|
|
// Send message off to player so they get it in client
|
|
const notificationMessage = this.notifierHelper.createNewMessageNotification(message);
|
|
this.notificationSendHelper.sendMessage(messageDetails.recipientId, notificationMessage);
|
|
}
|
|
|
|
/**
|
|
* Send a message from the player to an NPC
|
|
* @param sessionId Player id
|
|
* @param targetNpcId NPC message is sent to
|
|
* @param message Text to send to NPC
|
|
*/
|
|
public sendPlayerMessageToNpc(sessionId: string, targetNpcId: string, message: string): void
|
|
{
|
|
const playerProfile = this.saveServer.getProfile(sessionId);
|
|
const dialogWithNpc = playerProfile.dialogues[targetNpcId];
|
|
if (!dialogWithNpc)
|
|
{
|
|
this.logger.error(this.localisationService.getText("mailsend-missing_npc_dialog", targetNpcId));
|
|
|
|
return;
|
|
}
|
|
|
|
dialogWithNpc.messages.push({
|
|
_id: this.hashUtil.generate(),
|
|
dt: this.timeUtil.getTimestamp(),
|
|
hasRewards: false,
|
|
uid: playerProfile.characters.pmc._id,
|
|
type: MessageType.USER_MESSAGE,
|
|
rewardCollected: false,
|
|
text: message,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create a message for storage inside a dialog in the player profile
|
|
* @param senderDialog Id of dialog that will hold the message
|
|
* @param messageDetails Various details on what the message must contain/do
|
|
* @returns Message
|
|
*/
|
|
protected createDialogMessage(dialogId: string, messageDetails: ISendMessageDetails): Message
|
|
{
|
|
const message: Message = {
|
|
_id: this.hashUtil.generate(),
|
|
uid: dialogId, // must match the dialog id
|
|
type: messageDetails.sender, // Same enum is used for defining dialog type + message type, thanks bsg
|
|
dt: Math.round(Date.now() / 1000),
|
|
text: messageDetails.templateId ? "" : messageDetails.messageText, // store empty string if template id has value, otherwise store raw message text
|
|
templateId: messageDetails.templateId, // used by traders to send localised text from database\locales\global
|
|
hasRewards: false, // The default dialog message has no rewards, can be added later via addRewardItemsToMessage()
|
|
rewardCollected: false, // The default dialog message has no rewards, can be added later via addRewardItemsToMessage()
|
|
systemData: messageDetails.systemData ? messageDetails.systemData : undefined, // Used by ragfair / localised messages that need "location" or "time"
|
|
profileChangeEvents: (messageDetails.profileChangeEvents?.length === 0)
|
|
? messageDetails.profileChangeEvents
|
|
: undefined, // no one knows, its never been used in any dumps
|
|
};
|
|
|
|
// Clean up empty system data
|
|
if (!message.systemData)
|
|
{
|
|
delete message.systemData;
|
|
}
|
|
|
|
// Clean up empty template id
|
|
if (!message.templateId)
|
|
{
|
|
delete message.templateId;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
/**
|
|
* Add items to message and adjust various properties to reflect the items being added
|
|
* @param message Message to add items to
|
|
* @param itemsToSendToPlayer Items to add to message
|
|
* @param maxStorageTimeSeconds total time items are stored in mail before being deleted
|
|
*/
|
|
protected addRewardItemsToMessage(
|
|
message: Message,
|
|
itemsToSendToPlayer: MessageItems,
|
|
maxStorageTimeSeconds: number,
|
|
): void
|
|
{
|
|
if (itemsToSendToPlayer?.data?.length > 0)
|
|
{
|
|
message.items = itemsToSendToPlayer;
|
|
message.hasRewards = true;
|
|
message.maxStorageTime = maxStorageTimeSeconds;
|
|
message.rewardCollected = false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* perform various sanitising actions on the items before they're considered ready for insertion into message
|
|
* @param dialogType The type of the dialog that will hold the reward items being processed
|
|
* @param messageDetails
|
|
* @returns Sanitised items
|
|
*/
|
|
protected processItemsBeforeAddingToMail(dialogType: MessageType, messageDetails: ISendMessageDetails): MessageItems
|
|
{
|
|
const db = this.databaseServer.getTables().templates.items;
|
|
|
|
let itemsToSendToPlayer: MessageItems = {};
|
|
if (messageDetails.items?.length > 0)
|
|
{
|
|
// Find base item that should be the 'primary' + have its parent id be used as the dialogs 'stash' value
|
|
const parentItem = this.getBaseItemFromRewards(messageDetails.items);
|
|
if (!parentItem)
|
|
{
|
|
this.localisationService.getText("mailsend-missing_parent", {
|
|
traderId: messageDetails.trader,
|
|
sender: messageDetails.sender,
|
|
});
|
|
|
|
return itemsToSendToPlayer;
|
|
}
|
|
|
|
// No parent id, generate random id and add (doesn't need to be actual parentId from db, only unique)
|
|
if (!parentItem?.parentId)
|
|
{
|
|
parentItem.parentId = this.hashUtil.generate();
|
|
}
|
|
|
|
itemsToSendToPlayer = { stash: parentItem.parentId, data: [] };
|
|
|
|
// Ensure Ids are unique and cont collide with items in player inventory later
|
|
messageDetails.items = this.itemHelper.replaceIDs(null, messageDetails.items);
|
|
|
|
for (const reward of messageDetails.items)
|
|
{
|
|
// Ensure item exists in items db
|
|
const itemTemplate = db[reward._tpl];
|
|
if (!itemTemplate)
|
|
{
|
|
// Can happen when modded items are insured + mod is removed
|
|
this.logger.error(
|
|
this.localisationService.getText("dialog-missing_item_template", {
|
|
tpl: reward._tpl,
|
|
type: dialogType,
|
|
}),
|
|
);
|
|
|
|
continue;
|
|
}
|
|
|
|
// Ensure every 'base/root' item has the same parentId + has a slotid of 'main'
|
|
if (!("slotId" in reward) || reward.slotId === "hideout")
|
|
{
|
|
// Reward items NEED a parent id + slotid
|
|
reward.parentId = parentItem.parentId;
|
|
reward.slotId = "main";
|
|
}
|
|
|
|
// Item is sanitised and ready to be put into holding array
|
|
itemsToSendToPlayer.data.push(reward);
|
|
|
|
// Item can contain sub-items, add those to array e.g. ammo boxes
|
|
if (itemTemplate._props.StackSlots)
|
|
{
|
|
const stackSlotItems = this.itemHelper.generateItemsFromStackSlot(itemTemplate, reward._id);
|
|
for (const itemToAdd of stackSlotItems)
|
|
{
|
|
itemsToSendToPlayer.data.push(itemToAdd);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove empty data property if no rewards
|
|
if (itemsToSendToPlayer.data.length === 0)
|
|
{
|
|
delete itemsToSendToPlayer.data;
|
|
}
|
|
}
|
|
|
|
return itemsToSendToPlayer;
|
|
}
|
|
|
|
/**
|
|
* Try to find the most correct item to be the 'primary' item in a reward mail
|
|
* @param items Possible items to choose from
|
|
* @returns Chosen 'primary' item
|
|
*/
|
|
protected getBaseItemFromRewards(items: Item[]): Item
|
|
{
|
|
// Only one item in reward, return it
|
|
if (items?.length === 1)
|
|
{
|
|
return items[0];
|
|
}
|
|
|
|
// Find first item with slotId that indicates its a 'base' item
|
|
let item = items.find((x) => ["hideout", "main"].includes(x.slotId));
|
|
if (item)
|
|
{
|
|
return item;
|
|
}
|
|
|
|
// Not a singlular item + no items have a hideout/main slotid
|
|
// Look for first item without parent id
|
|
item = items.find((x) => !x.parentId);
|
|
if (item)
|
|
{
|
|
return item;
|
|
}
|
|
|
|
// Just return first item in array
|
|
return items[0];
|
|
}
|
|
|
|
/**
|
|
* Get a dialog with a specified entity (user/trader)
|
|
* Create and store empty dialog if none exists in profile
|
|
* @param messageDetails Data on what message should do
|
|
* @returns Relevant Dialogue
|
|
*/
|
|
protected getDialog(messageDetails: ISendMessageDetails): Dialogue
|
|
{
|
|
const dialogsInProfile = this.dialogueHelper.getDialogsForProfile(messageDetails.recipientId);
|
|
const senderId = this.getMessageSenderIdByType(messageDetails);
|
|
|
|
// Does dialog exist
|
|
let senderDialog = dialogsInProfile[senderId];
|
|
if (!senderDialog)
|
|
{
|
|
// Create if doesn't
|
|
dialogsInProfile[senderId] = {
|
|
_id: senderId,
|
|
type: messageDetails.dialogType ? messageDetails.dialogType : messageDetails.sender,
|
|
messages: [],
|
|
pinned: false,
|
|
new: 0,
|
|
attachmentsNew: 0,
|
|
};
|
|
|
|
senderDialog = dialogsInProfile[senderId];
|
|
}
|
|
|
|
return senderDialog;
|
|
}
|
|
|
|
/**
|
|
* Get the appropriate sender id by the sender enum type
|
|
* @param messageDetails
|
|
* @returns gets an id of the individual sending it
|
|
*/
|
|
protected getMessageSenderIdByType(messageDetails: ISendMessageDetails): string
|
|
{
|
|
if (messageDetails.sender === MessageType.SYSTEM_MESSAGE)
|
|
{
|
|
return this.systemSenderId;
|
|
}
|
|
|
|
if (messageDetails.sender === MessageType.NPC_TRADER || messageDetails.dialogType === MessageType.NPC_TRADER)
|
|
{
|
|
return this.traderHelper.getValidTraderIdByEnumValue(messageDetails.trader);
|
|
}
|
|
|
|
if (messageDetails.sender === MessageType.USER_MESSAGE)
|
|
{
|
|
return messageDetails.senderDetails?._id;
|
|
}
|
|
|
|
if (messageDetails.senderDetails?._id)
|
|
{
|
|
return messageDetails.senderDetails._id;
|
|
}
|
|
|
|
if (messageDetails.trader)
|
|
{
|
|
return this.traderHelper.getValidTraderIdByEnumValue(messageDetails.trader);
|
|
}
|
|
|
|
this.logger.warning(`Unable to handle message of type: ${messageDetails.sender}`);
|
|
}
|
|
}
|