Server/project/src/services/PaymentService.ts
Refringe 4ac12ef70a Formatting/Linting Changes (!168)
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
2023-11-16 21:42:06 +00:00

428 lines
15 KiB
TypeScript

import { inject, injectable } from "tsyringe";
import { HandbookHelper } from "@spt-aki/helpers/HandbookHelper";
import { InventoryHelper } from "@spt-aki/helpers/InventoryHelper";
import { ItemHelper } from "@spt-aki/helpers/ItemHelper";
import { PaymentHelper } from "@spt-aki/helpers/PaymentHelper";
import { TraderHelper } from "@spt-aki/helpers/TraderHelper";
import { IPmcData } from "@spt-aki/models/eft/common/IPmcData";
import { Item } from "@spt-aki/models/eft/common/tables/IItem";
import { IItemEventRouterResponse } from "@spt-aki/models/eft/itemEvent/IItemEventRouterResponse";
import { IProcessBuyTradeRequestData } from "@spt-aki/models/eft/trade/IProcessBuyTradeRequestData";
import { IProcessSellTradeRequestData } from "@spt-aki/models/eft/trade/IProcessSellTradeRequestData";
import { BackendErrorCodes } from "@spt-aki/models/enums/BackendErrorCodes";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { HttpResponseUtil } from "@spt-aki/utils/HttpResponseUtil";
@injectable()
export class PaymentService
{
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil,
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("HandbookHelper") protected handbookHelper: HandbookHelper,
@inject("TraderHelper") protected traderHelper: TraderHelper,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("InventoryHelper") protected inventoryHelper: InventoryHelper,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("PaymentHelper") protected paymentHelper: PaymentHelper,
)
{}
/**
* Take money and insert items into return to server request
* @param {IPmcData} pmcData Player profile
* @param {IProcessBuyTradeRequestData} request
* @param {string} sessionID
* @returns IItemEventRouterResponse
*/
public payMoney(
pmcData: IPmcData,
request: IProcessBuyTradeRequestData,
sessionID: string,
output: IItemEventRouterResponse,
): IItemEventRouterResponse
{
const trader = this.traderHelper.getTrader(request.tid, sessionID);
// Track the amounts of each type of currency involved in the trade.
const currencyAmounts: { [key: string]: number; } = {};
// Delete barter items and track currencies
for (const index in request.scheme_items)
{
// Find the corresponding item in the player's inventory.
const item = pmcData.Inventory.items.find((i) => i._id === request.scheme_items[index].id);
if (item !== undefined)
{
if (!this.paymentHelper.isMoneyTpl(item._tpl))
{
// If the item is not money, remove it from the inventory.
output = this.inventoryHelper.removeItem(pmcData, item._id, sessionID, output);
request.scheme_items[index].count = 0;
}
else
{
// If the item is money, add its count to the currencyAmounts object.
currencyAmounts[item._tpl] = (currencyAmounts[item._tpl] || 0) + request.scheme_items[index].count;
}
}
else
{
// Used by `SptInsure`
// Handle differently, `id` is the money type tpl
const currencyTpl = request.scheme_items[index].id;
currencyAmounts[currencyTpl] = (currencyAmounts[currencyTpl] || 0) + request.scheme_items[index].count;
}
}
// Track the total amount of all currencies.
let totalCurrencyAmount = 0;
// Loop through each type of currency involved in the trade.
for (const currencyTpl in currencyAmounts)
{
const currencyAmount = currencyAmounts[currencyTpl];
totalCurrencyAmount += currencyAmount;
if (currencyAmount > 0)
{
// Find money stacks in inventory and remove amount needed + update output object to inform client of changes
output = this.addPaymentToOutput(pmcData, currencyTpl, currencyAmount, sessionID, output);
// If there are warnings, exit early.
if (output.warnings.length > 0)
{
return output;
}
// Convert the amount to the trader's currency and update the sales sum.
const costOfPurchaseInCurrency = this.handbookHelper.fromRUB(
this.handbookHelper.inRUB(currencyAmount, currencyTpl),
this.paymentHelper.getCurrency(trader.currency),
);
pmcData.TradersInfo[request.tid].salesSum += costOfPurchaseInCurrency;
}
}
// If no currency-based payment is involved, handle it separately
if (totalCurrencyAmount === 0)
{
this.logger.debug(this.localisationService.getText("payment-zero_price_no_payment"));
// Convert the handbook price to the trader's currency and update the sales sum.
const costOfPurchaseInCurrency = this.handbookHelper.fromRUB(
this.getTraderItemHandbookPriceRouble(request.item_id, request.tid),
this.paymentHelper.getCurrency(trader.currency),
);
pmcData.TradersInfo[request.tid].salesSum += costOfPurchaseInCurrency;
}
this.traderHelper.lvlUp(request.tid, pmcData);
this.logger.debug("Item(s) taken. Status OK.");
return output;
}
/**
* Get the item price of a specific traders assort
* @param traderAssortId Id of assort to look up
* @param traderId Id of trader with assort
* @returns Handbook rouble price of item
*/
protected getTraderItemHandbookPriceRouble(traderAssortId: string, traderId: string): number
{
const purchasedAssortItem = this.traderHelper.getTraderAssortItemByAssortId(traderId, traderAssortId);
if (!purchasedAssortItem)
{
return 1;
}
const assortItemPriceRouble = this.handbookHelper.getTemplatePrice(purchasedAssortItem._tpl);
if (!assortItemPriceRouble)
{
this.logger.debug(
`No item price found for ${purchasedAssortItem._tpl} on trader: ${traderId} in assort: ${traderAssortId}`,
);
return 1;
}
return assortItemPriceRouble;
}
/**
* Receive money back after selling
* @param {IPmcData} pmcData
* @param {number} amount
* @param {IProcessSellTradeRequestData} body
* @param {IItemEventRouterResponse} output
* @param {string} sessionID
* @returns IItemEventRouterResponse
*/
public getMoney(
pmcData: IPmcData,
amount: number,
body: IProcessSellTradeRequestData,
output: IItemEventRouterResponse,
sessionID: string,
): IItemEventRouterResponse
{
const trader = this.traderHelper.getTrader(body.tid, sessionID);
const currency = this.paymentHelper.getCurrency(trader.currency);
let calcAmount = this.handbookHelper.fromRUB(this.handbookHelper.inRUB(amount, currency), currency);
const maxStackSize = this.databaseServer.getTables().templates.items[currency]._props.StackMaxSize;
let skip = false;
for (const item of pmcData.Inventory.items)
{
// item is not currency
if (item._tpl !== currency)
{
continue;
}
// item is not in the stash
if (!this.isItemInStash(pmcData, item))
{
continue;
}
if (item.upd.StackObjectsCount < maxStackSize)
{
if (item.upd.StackObjectsCount + calcAmount > maxStackSize)
{
// calculate difference
calcAmount -= maxStackSize - item.upd.StackObjectsCount;
item.upd.StackObjectsCount = maxStackSize;
}
else
{
skip = true;
item.upd.StackObjectsCount = item.upd.StackObjectsCount + calcAmount;
}
output.profileChanges[sessionID].items.change.push(item);
if (skip)
{
break;
}
}
}
if (!skip)
{
const request = {
items: [{
// eslint-disable-next-line @typescript-eslint/naming-convention
item_id: currency,
count: calcAmount,
}],
tid: body.tid,
};
output = this.inventoryHelper.addItem(pmcData, request, output, sessionID, null, false, null, true);
}
// set current sale sum
const saleSum = pmcData.TradersInfo[body.tid].salesSum + amount;
pmcData.TradersInfo[body.tid].salesSum = saleSum;
this.traderHelper.lvlUp(body.tid, pmcData);
return output;
}
/**
* Recursively checks if the given item is
* inside the stash, that is it has the stash as
* ancestor with slotId=hideout
*/
protected isItemInStash(pmcData: IPmcData, item: Item): boolean
{
let container = item;
while ("parentId" in container)
{
if (container.parentId === pmcData.Inventory.stash && container.slotId === "hideout")
{
return true;
}
container = pmcData.Inventory.items.find((i) => i._id === container.parentId);
if (!container)
{
break;
}
}
return false;
}
/**
* Remove currency from player stash/inventory and update client object with changes
* @param pmcData Player profile to find and remove currency from
* @param currencyTpl Type of currency to pay
* @param amountToPay money value to pay
* @param sessionID Session id
* @param output output object to send to client
* @returns IItemEventRouterResponse
*/
public addPaymentToOutput(
pmcData: IPmcData,
currencyTpl: string,
amountToPay: number,
sessionID: string,
output: IItemEventRouterResponse,
): IItemEventRouterResponse
{
const moneyItemsInInventory = this.getSortedMoneyItemsInInventory(
pmcData,
currencyTpl,
pmcData.Inventory.stash,
);
const amountAvailable = moneyItemsInInventory.reduce(
(accumulator, item) => accumulator + item.upd.StackObjectsCount,
0,
);
// If no money in inventory or amount is not enough we return false
if (moneyItemsInInventory.length <= 0 || amountAvailable < amountToPay)
{
this.logger.error(
this.localisationService.getText("payment-not_enough_money_to_complete_transation", {
amountToPay: amountToPay,
amountAvailable: amountAvailable,
}),
);
output = this.httpResponse.appendErrorToOutput(
output,
this.localisationService.getText("payment-not_enough_money_to_complete_transation_short"),
BackendErrorCodes.UNKNOWN_TRADING_ERROR,
);
return output;
}
let leftToPay = amountToPay;
for (const moneyItem of moneyItemsInInventory)
{
const itemAmount = moneyItem.upd.StackObjectsCount;
if (leftToPay >= itemAmount)
{
leftToPay -= itemAmount;
output = this.inventoryHelper.removeItem(pmcData, moneyItem._id, sessionID, output);
}
else
{
moneyItem.upd.StackObjectsCount -= leftToPay;
leftToPay = 0;
output.profileChanges[sessionID].items.change.push(moneyItem);
}
if (leftToPay === 0)
{
break;
}
}
return output;
}
/**
* Get all money stacks in inventory and prioritse items in stash
* @param pmcData
* @param currencyTpl
* @param playerStashId Players stash id
* @returns Sorting money items
*/
protected getSortedMoneyItemsInInventory(pmcData: IPmcData, currencyTpl: string, playerStashId: string): Item[]
{
const moneyItemsInInventory = this.itemHelper.findBarterItems("tpl", pmcData.Inventory.items, currencyTpl);
// Prioritise items in stash to top of array
moneyItemsInInventory.sort((a, b) => this.prioritiseStashSort(a, b, pmcData.Inventory.items, playerStashId));
return moneyItemsInInventory;
}
/**
* Prioritise player stash first over player inventory
* Post-raid healing would often take money out of the players pockets/secure container
* @param a First money stack item
* @param b Second money stack item
* @param inventoryItems players inventory items
* @param playerStashId Players stash id
* @returns sort order
*/
protected prioritiseStashSort(a: Item, b: Item, inventoryItems: Item[], playerStashId: string): number
{
// a in stash, prioritise
if (a.slotId === "hideout" && b.slotId !== "hideout")
{
return -1;
}
// b in stash, prioritise
if (a.slotId !== "hideout" && b.slotId === "hideout")
{
return 1;
}
// both in containers
if (a.slotId === "main" && b.slotId === "main")
{
// Item is in inventory, not stash, deprioritise
const aInStash = this.isInStash(a.parentId, inventoryItems, playerStashId);
const bInStash = this.isInStash(b.parentId, inventoryItems, playerStashId);
// a in stash, prioritise
if (aInStash && !bInStash)
{
return -1;
}
// b in stash, prioritise
if (!aInStash && bInStash)
{
return 1;
}
}
// they match
return 0;
}
/**
* Recursivly check items parents to see if it is inside the players inventory, not stash
* @param itemId item id to check
* @param inventoryItems player inventory
* @param playerStashId Players stash id
* @returns true if its in inventory
*/
protected isInStash(itemId: string, inventoryItems: Item[], playerStashId: string): boolean
{
const itemParent = inventoryItems.find((x) => x._id === itemId);
if (itemParent)
{
if (itemParent.slotId === "hideout")
{
return true;
}
if (itemParent._id === playerStashId)
{
return true;
}
return this.isInStash(itemParent.parentId, inventoryItems, playerStashId);
}
return false;
}
}