diff --git a/project/src/controllers/RagfairController.ts b/project/src/controllers/RagfairController.ts index 6c72c120..0139362d 100644 --- a/project/src/controllers/RagfairController.ts +++ b/project/src/controllers/RagfairController.ts @@ -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, diff --git a/project/src/helpers/RagfairHelper.ts b/project/src/helpers/RagfairHelper.ts index 30228e93..2069cd60 100644 --- a/project/src/helpers/RagfairHelper.ts +++ b/project/src/helpers/RagfairHelper.ts @@ -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[] diff --git a/project/src/helpers/RagfairOfferHelper.ts b/project/src/helpers/RagfairOfferHelper.ts index 6e8d350e..54ce71dc 100644 --- a/project/src/helpers/RagfairOfferHelper.ts +++ b/project/src/helpers/RagfairOfferHelper.ts @@ -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, diff --git a/project/src/helpers/RagfairSellHelper.ts b/project/src/helpers/RagfairSellHelper.ts index bca9f62d..c0870ae1 100644 --- a/project/src/helpers/RagfairSellHelper.ts +++ b/project/src/helpers/RagfairSellHelper.ts @@ -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 diff --git a/project/src/services/RagfairOfferService.ts b/project/src/services/RagfairOfferService.ts index cd80509c..7e749141 100644 --- a/project/src/services/RagfairOfferService.ts +++ b/project/src/services/RagfairOfferService.ts @@ -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; + } }