Fix flea selling issues (!374)

Co-authored-by: Dev <dev@dev.sp-tarkov.com>
Reviewed-on: https://dev.sp-tarkov.com/SPT/Server/pulls/374
This commit is contained in:
chomp 2024-07-15 18:24:23 +00:00
parent 77da49bb9e
commit 4fd113d00d
5 changed files with 349 additions and 93 deletions

View File

@ -414,8 +414,6 @@ export class RagfairController
{
const output = this.eventOutputHolder.getOutput(sessionID);
const fullProfile = this.saveServer.getProfile(sessionID);
const sellAsPack = offerRequest.sellInOnePiece; // a group of items that much be all purchased at once
const itemsToListCount = offerRequest.items.length; // Count of root items being sold (no children)
const validationMessage = "";
if (!this.isValidPlayerOfferRequest(offerRequest, validationMessage))
@ -429,60 +427,70 @@ export class RagfairController
return this.httpResponse.appendErrorToOutput(output, "Unknown offer type, cannot list item on flea");
}
switch (typeOfOffer)
{
case FleaOfferType.SINGLE:
return this.createSingleOffer(sessionID, offerRequest, fullProfile, output);
case FleaOfferType.MULTI:
return this.createMultiOffer(sessionID, offerRequest, fullProfile, output);
case FleaOfferType.PACK:
return this.createPackOffer(sessionID, offerRequest, fullProfile, output);
}
}
protected createSingleOffer(
sessionID: string,
offerRequest: IAddOfferRequestData,
fullProfile: ISptProfile,
output: IItemEventRouterResponse): IItemEventRouterResponse
{
const pmcData = fullProfile.characters.pmc;
const itemsToListCount = offerRequest.items.length; // Does not count stack size, only items
// Find items to be listed on flea from player inventory
const { items: itemsInInventoryToList, errorMessage: itemsInInventoryError }
const { items: itemsAndChildrenInInventoryToList, errorMessage: itemsInInventoryError }
= this.getItemsToListOnFleaFromInventory(pmcData, offerRequest.items);
if (!itemsInInventoryToList || itemsInInventoryError)
if (!itemsAndChildrenInInventoryToList || itemsInInventoryError)
{
this.httpResponse.appendErrorToOutput(output, itemsInInventoryError);
}
// Total count of items summed using their stack counts
const stackCountTotal = this.ragfairOfferHelper.getTotalStackCountSize(itemsAndChildrenInInventoryToList);
// Checks are done, create the offer
const playerListedPriceInRub = this.calculateRequirementsPriceInRub(offerRequest.requirements);
const offer = this.createPlayerOffer(
sessionID,
offerRequest.requirements,
this.ragfairHelper.mergeStackable(itemsInInventoryToList),
sellAsPack,
itemsAndChildrenInInventoryToList[0],
false,
);
const rootItem = offer.items[0];
// Get average of items quality+children
const qualityMultiplier = this.itemHelper.getItemQualityModifierForItems(offer.items, true);
let averageOfferPrice = this.ragfairPriceService.getFleaPriceForOfferItems(offer.items);
// Average offer price for single item (or whole weapon)
let averageOfferPriceSingleItem = this.ragfairPriceService.getFleaPriceForOfferItems(offer.items);
// Check for and apply item price modifer if it exists in config
const itemPriceModifer = this.ragfairConfig.dynamic.itemPriceMultiplier[rootItem._tpl];
if (itemPriceModifer)
{
averageOfferPrice *= itemPriceModifer;
averageOfferPriceSingleItem *= itemPriceModifer;
}
// Multiply single item price by quality
averageOfferPrice *= qualityMultiplier;
// Define packs as a single count item
const itemStackCount = sellAsPack
? 1
: itemsToListCount;
// Average out price of offer
const averageSingleItemPrice = sellAsPack
? averageOfferPrice / itemsToListCount // Packs contains multiple items sold as one
: averageOfferPrice / itemStackCount; // Normal offer, single items can be purchased from listing
// Get averaged price of player listing to use when calculating sell chance
const averagePlayerListedPriceInRub = sellAsPack
? playerListedPriceInRub / itemsToListCount
: playerListedPriceInRub;
averageOfferPriceSingleItem *= qualityMultiplier;
// Packs are reduced to the average price of a single item in the pack vs the averaged single price of an item
const sellChancePercent = this.ragfairSellHelper.calculateSellChance(
averageSingleItemPrice,
averagePlayerListedPriceInRub,
averageOfferPriceSingleItem,
playerListedPriceInRub,
qualityMultiplier,
);
offer.sellResult = this.ragfairSellHelper.rollForSale(sellChancePercent, itemStackCount);
offer.sellResult = this.ragfairSellHelper.rollForSale(sellChancePercent, itemsToListCount);
// Subtract flea market fee from stash
if (this.ragfairConfig.sell.fees)
@ -492,7 +500,217 @@ export class RagfairController
rootItem,
pmcData,
playerListedPriceInRub,
itemStackCount,
stackCountTotal,
offerRequest,
output,
);
if (taxFeeChargeFailed)
{
return output;
}
}
// Add offer to players profile + add to client response
fullProfile.characters.pmc.RagfairInfo.offers.push(offer);
output.profileChanges[sessionID].ragFairOffers.push(offer);
// Remove items from inventory after creating offer
for (const itemToRemove of offerRequest.items)
{
this.inventoryHelper.removeItem(pmcData, itemToRemove, sessionID, output);
}
return output;
}
protected createMultiOffer(
sessionID: string,
offerRequest: IAddOfferRequestData,
fullProfile: ISptProfile,
output: IItemEventRouterResponse): IItemEventRouterResponse
{
const pmcData = fullProfile.characters.pmc;
const itemsToListCount = offerRequest.items.length; // Does not count stack size, only items
// multi-offers are all the same item,
// Get first item and its children and use as template
const firstListingAndChidren = this.itemHelper.findAndReturnChildrenAsItems(
pmcData.Inventory.items,
offerRequest.items[0]);
// Find items to be listed on flea (+ children) from player inventory
const { items: itemsAndChildrenInInventoryToList, errorMessage: itemsInInventoryError }
= this.getItemsToListOnFleaFromInventory(pmcData, offerRequest.items);
if (!itemsAndChildrenInInventoryToList || itemsInInventoryError)
{
this.httpResponse.appendErrorToOutput(output, itemsInInventoryError);
}
// Total count of items summed using their stack counts
const stackCountTotal = this.ragfairOfferHelper.getTotalStackCountSize(itemsAndChildrenInInventoryToList);
// When listing identical items on flea, condense separate items into one stack with a merged stack count
// e.g. 2 ammo items, stackObjectCount = 3 for each, will result in 1 stack of 6
if (!firstListingAndChidren[0].upd)
{
firstListingAndChidren[0].upd = {};
}
firstListingAndChidren[0].upd.StackObjectsCount = stackCountTotal;
// Create flea object
const offer = this.createPlayerOffer(
sessionID,
offerRequest.requirements,
firstListingAndChidren,
false,
);
// This is the item that will be listed on flea, has merged stackObjectCount
const newRootOfferItem = offer.items[0];
// Average offer price for single item (or whole weapon)
let averageOfferPrice = this.ragfairPriceService.getFleaPriceForOfferItems(offer.items);
// Check for and apply item price modifer if it exists in config
const itemPriceModifer = this.ragfairConfig.dynamic.itemPriceMultiplier[newRootOfferItem._tpl];
if (itemPriceModifer)
{
averageOfferPrice *= itemPriceModifer;
}
// Get average of item+children quality
const qualityMultiplier = this.itemHelper.getItemQualityModifierForItems(offer.items, true);
// Multiply single item price by quality
averageOfferPrice *= qualityMultiplier;
// Get price player listed items for in roubles
const playerListedPriceInRub = this.calculateRequirementsPriceInRub(offerRequest.requirements);
// Roll sale chance
const sellChancePercent = this.ragfairSellHelper.calculateSellChance(
averageOfferPrice,
playerListedPriceInRub,
qualityMultiplier,
);
// Create array of sell times for items listed
offer.sellResult = this.ragfairSellHelper.rollForSale(sellChancePercent, itemsToListCount);
// Subtract flea market fee from stash
if (this.ragfairConfig.sell.fees)
{
const taxFeeChargeFailed = this.chargePlayerTaxFee(
sessionID,
newRootOfferItem,
pmcData,
playerListedPriceInRub,
stackCountTotal,
offerRequest,
output,
);
if (taxFeeChargeFailed)
{
return output;
}
}
// Add offer to players profile + add to client response
fullProfile.characters.pmc.RagfairInfo.offers.push(offer);
output.profileChanges[sessionID].ragFairOffers.push(offer);
// Remove items from inventory after creating offer
for (const itemToRemove of offerRequest.items)
{
this.inventoryHelper.removeItem(pmcData, itemToRemove, sessionID, output);
}
return output;
}
protected createPackOffer(
sessionID: string,
offerRequest: IAddOfferRequestData,
fullProfile: ISptProfile,
output: IItemEventRouterResponse): IItemEventRouterResponse
{
const pmcData = fullProfile.characters.pmc;
const itemsToListCount = offerRequest.items.length; // Does not count stack size, only items
// multi-offers are all the same item,
// Get first item and its children and use as template
const firstListingAndChidren = this.itemHelper.findAndReturnChildrenAsItems(
pmcData.Inventory.items,
offerRequest.items[0]);
// Find items to be listed on flea (+ children) from player inventory
const { items: itemsAndChildrenInInventoryToList, errorMessage: itemsInInventoryError }
= this.getItemsToListOnFleaFromInventory(pmcData, offerRequest.items);
if (!itemsAndChildrenInInventoryToList || itemsInInventoryError)
{
this.httpResponse.appendErrorToOutput(output, itemsInInventoryError);
}
// Total count of items summed using their stack counts
const stackCountTotal = this.ragfairOfferHelper.getTotalStackCountSize(itemsAndChildrenInInventoryToList);
// When listing identical items on flea, condense separate items into one stack with a merged stack count
// e.g. 2 ammo items, stackObjectCount = 3 for each, will result in 1 stack of 6
if (!firstListingAndChidren[0].upd)
{
firstListingAndChidren[0].upd = {};
}
firstListingAndChidren[0].upd.StackObjectsCount = stackCountTotal;
// Create flea object
const offer = this.createPlayerOffer(
sessionID,
offerRequest.requirements,
firstListingAndChidren,
true,
);
// This is the item that will be listed on flea, has merged stackObjectCount
const newRootOfferItem = offer.items[0];
// Single price for an item
let singleItemPrice = this.ragfairPriceService.getFleaPriceForItem(firstListingAndChidren[0]._tpl);
// Check for and apply item price modifer if it exists in config
const itemPriceModifer = this.ragfairConfig.dynamic.itemPriceMultiplier[newRootOfferItem._tpl];
if (itemPriceModifer)
{
singleItemPrice *= itemPriceModifer;
}
// Get average of item+children quality
const qualityMultiplier = this.itemHelper.getItemQualityModifierForItems(offer.items, true);
// Multiply single item price by quality
singleItemPrice *= qualityMultiplier;
// Get price player listed items for in roubles
const playerListedPriceInRub = this.calculateRequirementsPriceInRub(offerRequest.requirements);
// Roll sale chance
const sellChancePercent = this.ragfairSellHelper.calculateSellChance(
singleItemPrice * stackCountTotal,
playerListedPriceInRub,
qualityMultiplier,
);
// Create array of sell times for items listed + sell all at once as its a pack
offer.sellResult = this.ragfairSellHelper.rollForSale(sellChancePercent, itemsToListCount, true);
// Subtract flea market fee from stash
if (this.ragfairConfig.sell.fees)
{
const taxFeeChargeFailed = this.chargePlayerTaxFee(
sessionID,
newRootOfferItem,
pmcData,
playerListedPriceInRub,
stackCountTotal,
offerRequest,
output,
);
@ -539,7 +757,7 @@ export class RagfairController
* @param rootItem Base item being listed (used when client tax cost not found and must be done on server)
* @param pmcData Player profile
* @param requirementsPriceInRub Rouble cost player chose for listing (used when client tax cost not found and must be done on server)
* @param itemStackCount How many items were listed in player (used when client tax cost not found and must be done on server)
* @param itemStackCount How many items were listed by player (used when client tax cost not found and must be done on server)
* @param offerRequest Add offer request object from client
* @param output IItemEventRouterResponse
* @returns True if charging tax to player failed
@ -645,9 +863,9 @@ export class RagfairController
protected getItemsToListOnFleaFromInventory(
pmcData: IPmcData,
itemIdsFromFleaOfferRequest: string[],
): { items: Item[] | undefined, errorMessage: string | undefined }
): { items: Item[][] | undefined, errorMessage: string | undefined }
{
const itemsToReturn = [];
const itemsToReturn: Item[][] = [];
let errorMessage: string | undefined = undefined;
// Count how many items are being sold and multiply the requested amount accordingly
@ -665,7 +883,7 @@ export class RagfairController
}
item = this.itemHelper.fixItemStackCount(item);
itemsToReturn.push(...this.itemHelper.findAndReturnChildrenAsItems(pmcData.Inventory.items, itemId));
itemsToReturn.push(this.itemHelper.findAndReturnChildrenAsItems(pmcData.Inventory.items, itemId));
}
if (!itemsToReturn?.length)
@ -689,12 +907,7 @@ export class RagfairController
const loyalLevel = 1;
const formattedItems: Item[] = items.map((item) =>
{
const isChild = items.some((it) => it._id === item.parentId);
if (!isChild && !sellInOnePiece)
{
// Ensure offer with multiple of an item has its stack count reset
item.upd.StackObjectsCount = 1;
}
const isChild = items.some((subItem) => subItem._id === item.parentId);
return {
_id: item._id,

View File

@ -147,7 +147,7 @@ export class RagfairHelper
}
/**
* Merges Root Items
* Iterate over array of identical items and merge stack count
* Ragfair allows abnormally large stacks.
*/
public mergeStackable(items: Item[]): Item[]

View File

@ -333,7 +333,8 @@ export class RagfairOfferHelper
for (const offer of profileOffers.values())
{
if (offer.sellResult && offer.sellResult.length > 0 && timestamp >= offer.sellResult[0].sellTime)
if (offer.sellResult?.length > 0
&& timestamp >= offer.sellResult[0].sellTime)
{
// Item sold
let totalItemsCount = 1;
@ -341,7 +342,8 @@ export class RagfairOfferHelper
if (!offer.sellInOnePiece)
{
totalItemsCount = offer.items.reduce((sum: number, item) => sum + item.upd.StackObjectsCount, 0);
// offer.items.reduce((sum, item) => sum + item.upd?.StackObjectsCount ?? 0, 0);
totalItemsCount = this.getTotalStackCountSize([offer.items]);
boughtAmount = offer.sellResult[0].amount;
}
@ -358,6 +360,28 @@ export class RagfairOfferHelper
return true;
}
/**
* Count up all rootitem StackObjectsCount properties of an array of items
* @param itemsInInventoryToList items to sum up
* @returns Total count
*/
public getTotalStackCountSize(itemsInInventoryToList: Item[][]): number
{
let total = 0;
for (const itemAndChildren of itemsInInventoryToList)
{
for (const item of itemAndChildren)
{
if (item.slotId === "hideout")
{
total += item.upd?.StackObjectsCount ?? 1;
}
}
}
return total;
}
/**
* Add amount to players ragfair rating
* @param sessionId Profile to update
@ -422,64 +446,23 @@ export class RagfairOfferHelper
protected completeOffer(sessionID: string, offer: IRagfairOffer, boughtAmount: number): IItemEventRouterResponse
{
const itemTpl = offer.items[0]._tpl;
let itemsToSend = [];
let paymentItemsToSendToPlayer: Item[] = [];
const offerStackCount = offer.items[0].upd.StackObjectsCount;
// Pack or ALL items of a multi-offer were bought - remove entire ofer
if (offer.sellInOnePiece || boughtAmount === offerStackCount)
{
this.deleteOfferById(sessionID, offer._id);
}
else
{
offer.items[0].upd.StackObjectsCount -= boughtAmount;
const rootItems = offer.items.filter((i) => i.parentId === "hideout");
rootItems.splice(0, 1);
const offerRootItem = offer.items[0];
let removeCount = boughtAmount;
let idsToRemove: string[] = [];
while (removeCount > 0 && rootItems.length > 0)
{
const lastItem = rootItems[rootItems.length - 1];
if (lastItem.upd.StackObjectsCount > removeCount)
{
lastItem.upd.StackObjectsCount -= removeCount;
removeCount = 0;
}
else
{
removeCount -= lastItem.upd.StackObjectsCount;
idsToRemove.push(lastItem._id);
rootItems.splice(rootItems.length - 1, 1);
}
}
let foundNewItems = true;
while (foundNewItems)
{
foundNewItems = false;
for (const id of idsToRemove)
{
const newIds = offer.items
.filter((i) => !idsToRemove.includes(i._id) && idsToRemove.includes(i.parentId))
.map((i) => i._id);
if (newIds.length > 0)
{
foundNewItems = true;
idsToRemove = [...idsToRemove, ...newIds];
}
}
}
if (idsToRemove.length > 0)
{
offer.items = offer.items.filter((i) => !idsToRemove.includes(i._id));
}
// Reduce offer root items stack count
offerRootItem.upd.StackObjectsCount -= boughtAmount;
}
// Assemble the payment item(s)
// Assemble payment to send to seller now offer was purchased
for (const requirement of offer.requirements)
{
// Create an item template item
@ -504,7 +487,7 @@ export class RagfairOfferHelper
}
}
itemsToSend = [...itemsToSend, ...outItems];
paymentItemsToSendToPlayer = [...paymentItemsToSendToPlayer, ...outItems];
}
}
@ -519,7 +502,7 @@ export class RagfairOfferHelper
this.traderHelper.getTraderById(Traders.RAGMAN),
MessageType.FLEAMARKET_MESSAGE,
this.getLocalisedOfferSoldMessage(itemTpl, boughtAmount),
itemsToSend,
paymentItemsToSendToPlayer,
this.timeUtil.getHoursAsSeconds(
this.questHelper.getMailItemRedeemTimeHoursForProfile(this.profileHelper.getPmcProfile(sessionID))),
undefined,

View File

@ -65,9 +65,10 @@ export class RagfairSellHelper
* Get array of item count and sell time (empty array = no sell)
* @param sellChancePercent chance item will sell
* @param itemSellCount count of items to sell
* @param sellInOneGo All items listed get sold at once
* @returns Array of purchases of item(s) listed
*/
public rollForSale(sellChancePercent: number, itemSellCount: number): SellResult[]
public rollForSale(sellChancePercent: number, itemSellCount: number, sellInOneGo = false): SellResult[]
{
const startTime = this.timeUtil.getTimestamp();
@ -103,7 +104,9 @@ export class RagfairSellHelper
while (remainingCount > 0 && sellTime < endTime)
{
const boughtAmount = this.randomUtil.getInt(1, remainingCount);
const boughtAmount = (sellInOneGo)
? remainingCount
: this.randomUtil.getInt(1, remainingCount);
if (this.randomUtil.getChance100(effectiveSellChance))
{
// Passed roll check, item will be sold

View File

@ -1,4 +1,5 @@
import { inject, injectable } from "tsyringe";
import { ItemHelper } from "@spt/helpers/ItemHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { RagfairServerHelper } from "@spt/helpers/RagfairServerHelper";
import { Item } from "@spt/models/eft/common/tables/IItem";
@ -11,6 +12,7 @@ import { ConfigServer } from "@spt/servers/ConfigServer";
import { SaveServer } from "@spt/servers/SaveServer";
import { DatabaseService } from "@spt/services/DatabaseService";
import { LocalisationService } from "@spt/services/LocalisationService";
import { ICloner } from "@spt/utils/cloners/ICloner";
import { HttpResponseUtil } from "@spt/utils/HttpResponseUtil";
import { RagfairOfferHolder } from "@spt/utils/RagfairOfferHolder";
import { TimeUtil } from "@spt/utils/TimeUtil";
@ -31,11 +33,13 @@ export class RagfairOfferService
@inject("DatabaseService") protected databaseService: DatabaseService,
@inject("SaveServer") protected saveServer: SaveServer,
@inject("RagfairServerHelper") protected ragfairServerHelper: RagfairServerHelper,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder,
@inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("ConfigServer") protected configServer: ConfigServer,
@inject("PrimaryCloner") protected cloner: ICloner,
)
{
this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR);
@ -276,7 +280,60 @@ export class RagfairOfferService
this.ragfairOfferHandler.removeOffer(playerOffer);
// Send failed offer items to player in mail
this.ragfairServerHelper.returnItems(profile.sessionId, playerOffer.items);
const unstackedItems = this.unstackOfferItems(playerOffer.items);
this.ragfairServerHelper.returnItems(profile.sessionId, unstackedItems);
profile.RagfairInfo.offers.splice(offerinProfileIndex, 1);
}
/**
* Flea offer items are stacked up often beyond the StackMaxSize limit
* Un stack the items into an array of root items and their children
* Will create new items equal to the
* @param items Offer items to unstack
* @returns Unstacked array of items
*/
protected unstackOfferItems(items: Item[]): Item[]
{
const result: Item[] = [];
const rootItem = items[0];
const itemDetails = this.itemHelper.getItem(rootItem._tpl);
const itemMaxStackSize = itemDetails[1]._props.StackMaxSize ?? 1;
const totalItemCount = rootItem.upd?.StackObjectsCount ?? 1;
// Items within stack tolerance, return existing data - no changes needed
if (totalItemCount <= itemMaxStackSize)
{
return items;
}
// Single item with no children e.g. ammo, use existing de-stacking code
if (items.length === 1)
{
return this.itemHelper.splitStack(rootItem);
}
// Item with children, needs special handling
// Force new item to have stack size of 1
for (let index = 0; index < totalItemCount; index++)
{
const itemAndChildrenClone = this.cloner.clone(items);
// Ensure upd object exits
itemAndChildrenClone[0].upd ||= {};
// Force item to be singular
itemAndChildrenClone[0].upd.StackObjectsCount = 1;
// Ensure items IDs are unique to prevent collisions when added to player inventory
const reparentedItemAndChildren = this.itemHelper.reparentItemAndChildren(
itemAndChildrenClone[0],
itemAndChildrenClone);
this.itemHelper.remapRootItemId(reparentedItemAndChildren);
result.push(...reparentedItemAndChildren);
}
return result;
}
}