0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:50:43 -05:00
server/project/src/controllers/InsuranceController.ts

470 lines
20 KiB
TypeScript
Raw Normal View History

2023-03-03 15:23:46 +00:00
import { inject, injectable } from "tsyringe";
import { DialogueHelper } from "../helpers/DialogueHelper";
import { ItemHelper } from "../helpers/ItemHelper";
import { ProfileHelper } from "../helpers/ProfileHelper";
import { TraderHelper } from "../helpers/TraderHelper";
2023-03-03 15:23:46 +00:00
import { IPmcData } from "../models/eft/common/IPmcData";
import { Item } from "../models/eft/common/tables/IItem";
import { ITemplateItem } from "../models/eft/common/tables/ITemplateItem";
2023-03-03 15:23:46 +00:00
import { IGetInsuranceCostRequestData } from "../models/eft/insurance/IGetInsuranceCostRequestData";
import {
IGetInsuranceCostResponseData
} from "../models/eft/insurance/IGetInsuranceCostResponseData";
import { IInsureRequestData } from "../models/eft/insurance/IInsureRequestData";
import { IItemEventRouterResponse } from "../models/eft/itemEvent/IItemEventRouterResponse";
import { Insurance } from "../models/eft/profile/IAkiProfile";
2023-03-03 15:23:46 +00:00
import { IProcessBuyTradeRequestData } from "../models/eft/trade/IProcessBuyTradeRequestData";
import { ConfigTypes } from "../models/enums/ConfigTypes";
import { MessageType } from "../models/enums/MessageType";
2023-03-03 15:23:46 +00:00
import { IInsuranceConfig } from "../models/spt/config/IInsuranceConfig";
import { ILogger } from "../models/spt/utils/ILogger";
import { EventOutputHolder } from "../routers/EventOutputHolder";
import { ConfigServer } from "../servers/ConfigServer";
import { DatabaseServer } from "../servers/DatabaseServer";
import { SaveServer } from "../servers/SaveServer";
import { InsuranceService } from "../services/InsuranceService";
import { MailSendService } from "../services/MailSendService";
2023-03-03 15:23:46 +00:00
import { PaymentService } from "../services/PaymentService";
import { RandomUtil } from "../utils/RandomUtil";
import { TimeUtil } from "../utils/TimeUtil";
@injectable()
export class InsuranceController
{
protected insuranceConfig: IInsuranceConfig;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("SaveServer") protected saveServer: SaveServer,
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("DialogueHelper") protected dialogueHelper: DialogueHelper,
@inject("TraderHelper") protected traderHelper: TraderHelper,
2023-03-03 15:23:46 +00:00
@inject("PaymentService") protected paymentService: PaymentService,
@inject("InsuranceService") protected insuranceService: InsuranceService,
@inject("MailSendService") protected mailSendService: MailSendService,
2023-03-03 15:23:46 +00:00
@inject("ConfigServer") protected configServer: ConfigServer
)
{
this.insuranceConfig = this.configServer.getConfig(ConfigTypes.INSURANCE);
}
/**
* Process insurance items of all profiles prior to being given back to the player through the mail service.
*
* @returns void
*/
2023-03-03 15:23:46 +00:00
public processReturn(): void
{
// Process each installed profile.
2023-03-03 15:23:46 +00:00
for (const sessionID in this.saveServer.getProfiles())
{
this.processReturnByProfile(sessionID);
}
}
2023-03-03 15:23:46 +00:00
/**
* Process insurance items of a single profile prior to being given back to the player through the mail service.
*
* @returns void
*/
public processReturnByProfile(sessionID: string): void
{
// Filter out items that don't need to be processed yet.
const insuranceDetails = this.filterInsuredItems(sessionID);
2023-03-03 15:23:46 +00:00
// Skip profile if no insured items to process
if (insuranceDetails.length === 0)
{
return;
}
2023-03-03 15:23:46 +00:00
this.processInsuredItems(insuranceDetails, sessionID);
}
2023-03-03 15:23:46 +00:00
/**
* Get all insured items that are ready to be processed in a specific profile.
*
* @param sessionID Session ID of the profile to check.
* @param time The time to check ready status against. Current time by default.
* @returns All insured items that are ready to be processed.
*/
protected filterInsuredItems(sessionID: string, time?: number): Insurance[]
{
// Use the current time by default.
if (!time)
{
time = this.timeUtil.getTimestamp();
}
2023-03-03 15:23:46 +00:00
const profileInsuranceDetails = this.saveServer.getProfile(sessionID).insurance;
this.logger.debug(`Found ${profileInsuranceDetails.length} insurance packages in profile ${sessionID}`);
2023-03-03 15:23:46 +00:00
return profileInsuranceDetails.filter((insured) => time >= insured.scheduledTime);
}
/**
* This method orchestrates the processing of insured items in a profile.
*
* @param insuranceDetails The insured items to process.
* @param sessionID The session ID that should receive the processed items.
* @returns void
*/
protected processInsuredItems(insuranceDetails: Insurance[], sessionID: string): void
{
this.logger.debug(`Processing ${insuranceDetails.length} insurance packages, which include ${insuranceDetails.map(ins => ins.items.length).reduce((acc, len) => acc + len, 0)} items, for profile ${sessionID}`);
2023-03-03 15:23:46 +00:00
// We start from the end of the array and move towards the beginning, removing elements as we go. This way, the
// indices of the elements that have not been processed yet do not change, which ensures deletions never miss.
for (let i = insuranceDetails.length - 1; i >= 0; i--)
{
const insured = insuranceDetails[i];
// Find items that should be deleted from the insured items.
const itemsToDelete = this.findItemsToDelete(insured);
// Actually remove them.
this.removeItemsFromInsurance(insured, itemsToDelete);
// Send the mail to the player.
this.sendMail(sessionID, insured, insured.items.length === 0);
// Remove the insurance package from the profile now that it's been fully processed.
this.saveServer.getProfile(sessionID).insurance.splice(i, 1);
}
}
/**
* Build an array of items to delete from the insured items.
*
* This method orchestrates several steps:
* - Filters items based on their presence in the database and their raid moddability.
* - Sorts base and independent child items to consider for deletion.
* - Groups child items by their parent for later evaluation.
* - Evaluates grouped child items to decide which should be deleted, based on their value and a random roll.
*
* @param insured - The insured items to build a removal array from.
* @returns An array of IDs representing items that should be deleted.
*/
protected findItemsToDelete(insured: Insurance): string[]
{
const toDelete: string[] = [];
const childrenGroupedByParent = new Map<string, Item[]>();
insured.items.forEach(insuredItem =>
{
const itemDbDetails = this.itemHelper.getItem(insuredItem._tpl);
// Use the _tpl property from the parent item to get the parent item details
const parentItem = insured.items.find(item => item._id === insuredItem.parentId);
const parentItemDbDetailsArray = parentItem ? this.itemHelper.getItem(parentItem._tpl) : null;
const parentItemDbDetails = parentItemDbDetailsArray ? parentItemDbDetailsArray[1] : null;
// Filter out items not in the database or not raid moddable.
if (!this.filterByRaidModdability(insuredItem, parentItemDbDetails, itemDbDetails)) return;
// Check for base or independent child items.
if (this.isBaseOrIndependentChild(insuredItem))
{
// Find child IDs if the item is a parent.
const itemWithChildren = this.itemHelper.findAndReturnChildrenByItems(insured.items, insuredItem._id);
// Make a roll to decide if this item should be deleted, and if so, add it and its children to the deletion list.
if (this.makeRollAndMarkForDeletion(insuredItem, insured.traderId, toDelete))
2023-03-03 15:23:46 +00:00
{
toDelete.push(...itemWithChildren);
2023-03-03 15:23:46 +00:00
}
}
else if (insuredItem.parentId)
{
// This is a child item equipped to a parent... Group this child item by its parent.
this.groupChildrenByParent(insuredItem, childrenGroupedByParent);
}
});
// Iterate through each group of children and sort and filter them for deletion.
childrenGroupedByParent.forEach((children) =>
{
this.sortAndFilterChildren(children, insured.traderId, toDelete);
});
this.logger.debug(`Marked ${toDelete.length} items for deletion from insurance.`);
return toDelete;
}
2023-03-03 15:23:46 +00:00
/**
* Filters an item based on its existence in the database, raid moddability, and slot requirements.
*
* @param item The item to be filtered.
* @param parentItemDbDetails The database details of the parent item, or null if the item has no parent.
* @param itemDbDetails A tuple where the first element is a boolean indicating if the item exists in the database,
* and the second element is the item details if it does.
* @returns true if the item exists in the database and neither of the following conditions are met:
* - The item has the RaidModdable property set to false.
* - The item is attached to a required slot in its parent item.
* Otherwise, returns false.
*/
protected filterByRaidModdability(item: Item, parentItemDbDetails: ITemplateItem | null, itemDbDetails: [boolean, ITemplateItem]): boolean
{
// Check for RaidModdable property.
const isNotRaidModdable = itemDbDetails[1]?._props?.RaidModdable === false;
// Check for Slots in parent item details.
let isRequiredSlot = false;
if (parentItemDbDetails?._props?.Slots)
{
// Check if a Slot in parent details matches the slotId of the current item and is marked as required
isRequiredSlot = parentItemDbDetails._props.Slots.some(slot => slot._name === item.slotId && slot._required);
}
return itemDbDetails[0] && !(isNotRaidModdable || isRequiredSlot);
}
/**
* Determines if an item is either a base item or a child item that is not equipped to its parent.
*
* @param item The item to check.
* @returns true if the item is a base or an independent child item, otherwise false.
*/
protected isBaseOrIndependentChild(item: Item): boolean
{
return item.slotId === "hideout" || item.slotId === "main" || !isNaN(Number(item.slotId));
}
/**
* Makes a roll to determine if a given item should be deleted. If the roll is successful, the item's ID is added
* to the `toDelete` array.
*
* @param item The item for which the roll is made.
* @param traderId The ID of the trader to consider in the rollForItemDelete method.
* @param toDelete The array accumulating the IDs of items to be deleted.
* @returns true if the item is marked for deletion, otherwise false.
*/
protected makeRollAndMarkForDeletion(item: Item, traderId: string, toDelete: string[]): boolean
{
if (this.rollForItemDelete(item, traderId, toDelete))
{
toDelete.push(item._id);
return true;
}
return false;
}
/**
* Groups child items by their parent IDs in a Map data structure.
*
* @param item The child item to be grouped by its parent.
* @param childrenGroupedByParent The Map that holds arrays of children items grouped by their parent IDs.
* @returns void
*/
protected groupChildrenByParent(item: Item, childrenGroupedByParent: Map<string, Item[]>): void
{
if (!childrenGroupedByParent.has(item.parentId!))
{
childrenGroupedByParent.set(item.parentId!, []);
}
childrenGroupedByParent.get(item.parentId!)?.push(item);
}
2023-03-03 15:23:46 +00:00
/**
* Sorts the array of children items in descending order by their maximum price. For each child, a roll is made to
* determine if it should be deleted. The method then deletes the most valuable children based on the number of
* successful rolls made.
*
* @param children The array of children items to sort and filter.
* @param traderId The ID of the trader to consider in the rollForItemDelete method.
* @param toDelete The array that accumulates the IDs of the items to be deleted.
* @returns void
*/
protected sortAndFilterChildren(children: Item[], traderId: string, toDelete: string[]): void
{
// Sort the children by their max price in descending order.
children.sort((a, b) => this.itemHelper.getItemMaxPrice(b._tpl) - this.itemHelper.getItemMaxPrice(a._tpl));
// Count the number of successful rolls.
let successfulRolls = 0;
for (const child of children)
{
if (this.rollForItemDelete(child, traderId, toDelete))
{
successfulRolls++;
2023-03-03 15:23:46 +00:00
}
}
// Delete the most valuable children based on the number of successful rolls.
const mostValuableChildrenToDelete = children.slice(0, successfulRolls).map(child => child._id);
toDelete.push(...mostValuableChildrenToDelete);
}
2023-03-03 15:23:46 +00:00
/**
* Remove items from the insured items that should not be returned to the player.
*
* @param insured The insured items to process.
* @param toDelete The items that should be deleted.
* @returns void
*/
protected removeItemsFromInsurance(insured: Insurance, toDelete: string[]): void
{
insured.items = insured.items.filter(item => !toDelete.includes(item._id));
}
/**
* Handle sending the insurance message to the user that potentially contains the valid insurance items.
*
* @param sessionID The session ID that should receive the insurance message.
* @param insurance The context of insurance to use.
* @param noItems Whether or not there are any items to return to the player.
* @returns void
*/
protected sendMail(sessionID: string, insurance: Insurance, noItems: boolean): void
{
// After all of the item filtering that we've done, if there are no items remaining, the insurance has
// successfully "failed" to return anything and an appropriate message should be sent to the player.
if (noItems)
{
const insuranceFailedTemplates = this.databaseServer.getTables().traders[insurance.traderId].dialogue.insuranceFailed;
insurance.messageContent.templateId = this.randomUtil.getArrayValue(insuranceFailedTemplates);
2023-03-03 15:23:46 +00:00
}
// Send the insurance message
this.mailSendService.sendLocalisedNpcMessageToPlayer(
sessionID,
this.traderHelper.getTraderById(insurance.traderId),
MessageType.INSURANCE_RETURN,
insurance.messageContent.templateId,
insurance.items,
insurance.messageContent.maxStorageTime,
insurance.messageContent.systemData
);
2023-03-03 15:23:46 +00:00
}
/**
* Determines whether a valid insured item should be removed from the player's inventory based on a random roll and
* trader-specific return chance.
*
* @param insuredItem The insured item being evaluated for removal.
* @param traderId The ID of the trader who insured the item.
* @param itemsBeingDeleted List of items that are already slated for removal.
* @returns true if the insured item should be removed from inventory, false otherwise.
*/
protected rollForItemDelete(insuredItem: Item, traderId: string, itemsBeingDeleted: string[]): boolean
{
const maxRoll = 9999;
const conversionFactor = 100;
const returnChance = this.randomUtil.getInt(0, maxRoll) / conversionFactor;
const traderReturnChance = this.insuranceConfig.returnChancePercent[traderId];
const exceedsTraderReturnChance = returnChance >= traderReturnChance;
const isItemAlreadyBeingDeleted = itemsBeingDeleted.includes(insuredItem._id);
return exceedsTraderReturnChance && !isItemAlreadyBeingDeleted;
}
2023-03-03 15:23:46 +00:00
/**
* Handle Insure event
2023-03-03 15:23:46 +00:00
* Add insurance to an item
*
2023-03-03 15:23:46 +00:00
* @param pmcData Player profile
* @param body Insurance request
* @param sessionID Session id
* @returns IItemEventRouterResponse object to send to client
*/
public insure(pmcData: IPmcData, body: IInsureRequestData, sessionID: string): IItemEventRouterResponse
{
let output = this.eventOutputHolder.getOutput(sessionID);
const itemsToPay = [];
const inventoryItemsHash = {};
for (const item of pmcData.Inventory.items)
{
inventoryItemsHash[item._id] = item;
}
// get the price of all items
for (const key of body.items)
{
itemsToPay.push({
id: inventoryItemsHash[key]._id,
count: Math.round(this.insuranceService.getPremium(pmcData, inventoryItemsHash[key], body.tid))
});
}
const options: IProcessBuyTradeRequestData = {
// eslint-disable-next-line @typescript-eslint/naming-convention
scheme_items: itemsToPay,
tid: body.tid,
Action: "",
type: "",
// eslint-disable-next-line @typescript-eslint/naming-convention
item_id: "",
count: 0,
// eslint-disable-next-line @typescript-eslint/naming-convention
scheme_id: 0
};
// pay for the item insurance
output = this.paymentService.payMoney(pmcData, options, sessionID, output);
if (output.warnings.length > 0)
{
return output;
}
// add items to InsuredItems list once money has been paid
for (const key of body.items)
{
pmcData.InsuredItems.push({
tid: body.tid,
itemId: inventoryItemsHash[key]._id
});
}
return output;
}
/**
* Handle client/insurance/items/list/cost
2023-03-03 15:23:46 +00:00
* Calculate insurance cost
*
* @param request request object
2023-03-03 15:23:46 +00:00
* @param sessionID session id
* @returns IGetInsuranceCostResponseData object to send to client
*/
public cost(request: IGetInsuranceCostRequestData, sessionID: string): IGetInsuranceCostResponseData
2023-03-03 15:23:46 +00:00
{
const output: IGetInsuranceCostResponseData = {};
const pmcData = this.profileHelper.getPmcProfile(sessionID);
const inventoryItemsHash: Record<string, Item> = {};
for (const item of pmcData.Inventory.items)
{
inventoryItemsHash[item._id] = item;
}
// Loop over each trader in request
for (const trader of request.traders)
2023-03-03 15:23:46 +00:00
{
const items: Record<string, number> = {};
2023-03-03 15:23:46 +00:00
for (const itemId of request.items)
2023-03-03 15:23:46 +00:00
{
// Ensure hash has item in it
if (!inventoryItemsHash[itemId])
{
this.logger.debug(`Item with id: ${itemId} missing from player inventory, skipping`);
continue;
}
items[inventoryItemsHash[itemId]._tpl] = Math.round(this.insuranceService.getPremium(pmcData, inventoryItemsHash[itemId], trader));
2023-03-03 15:23:46 +00:00
}
output[trader] = items;
}
return output;
}
}