diff --git a/project/assets/configs/hideout.json b/project/assets/configs/hideout.json index 3b320b01..b7457a26 100644 --- a/project/assets/configs/hideout.json +++ b/project/assets/configs/hideout.json @@ -16,68 +16,70 @@ "min": 0.7, "max": 1.4 }, + "bonusChanceMultiplier": 0.25, + "bonusAmountMultiplier": 0.43, + "highValueThresholdRub": 70000, + "hideoutTaskRewardTimeSeconds": 21600, + "hideoutCraftSacrificeThresholdRub": 400000, "craftTimeThreshholds": [ { "min": 1, - "max": 25000, - "timeSeconds": 21600 + "max": 350000, + "craftTimeSeconds": 43200 }, { - "min": 25001, + "min": 350001, + "max": 399999, + "craftTimeSeconds": 50400 + }, + { + "min": 400000, "max": 99999999, - "timeSeconds": 43200 + "craftTimeSeconds": 50400 } ], - "craftTimeOverride": 40, - "directRewards": { - "66572c82ad599021091c6118": { "rewardTpls": ["5c0e874186f7745dc7616606"], "craftTimeSeconds": 3960 }, - "5aa2b986e5b5b00014028f4c": { "rewardTpls": ["62a091170b9d3c46de5b6cf2"], "craftTimeSeconds": 3960 }, - "655c663a6689c676ce57af85": { "rewardTpls": ["5c0e655586f774045612eeb2"], "craftTimeSeconds": 3960 }, - "655c652d60d0ac437100fed7": { "rewardTpls": ["590c657e86f77412b013051d"], "craftTimeSeconds": 3960 }, - "655c67782a1356436041c9c5": { - "rewardTpls": ["59e3577886f774176a362503", "5ed5166ad380ab312177c100"], - "craftTimeSeconds": 3960 - }, - "655c673673a43e23e857aebd": { - "rewardTpls": ["572b7adb24597762ae139821", "56e335e4d2720b6c058b456d"], - "craftTimeSeconds": 3960 - }, - "655c66e40b2de553b618d4b8": { - "rewardTpls": ["5d40407c86f774318526545a", "5d40407c86f774318526545a", "5d40407c86f774318526545a"], - "craftTimeSeconds": 3960 - }, - "6582dbf0b8d7830efc45016f": { "rewardTpls": ["5d1b376e86f774252519444e"], "craftTimeSeconds": 3960 }, - "66572be36a723f7f005a066e": { "rewardTpls": ["5b3b713c5acfc4330140bd8d"], "craftTimeSeconds": 3960 }, - "655c669103999d3c810c025b": { - "rewardTpls": ["635267ab3c89e2112001f826", "5fc64ea372b0dd78d51159dc"], - "craftTimeSeconds": 3960 - }, - "66572cbdad599021091c611a": { "rewardTpls": ["60a7ad2a2198820d95707a2e"], "craftTimeSeconds": 3960 }, - "5c0530ee86f774697952d952": { "rewardTpls": ["6389c8c5dbfd5e4b95197e6b"], "craftTimeSeconds": 43260 }, - "66572b8d80b1cd4b6a67847f": { - "rewardTpls": ["5bc9b9ecd4351e3bac122519", "62a09dd4621468534a797ac7"], - "craftTimeSeconds": 3960 - }, - "5c093ca986f7740a1867ab12": { "rewardTpls": ["5732ee6a24597719ae0c0281"], "craftTimeSeconds": 3960 }, - "665ee77ccf2d642e98220bca": { "rewardTpls": ["5857a8bc2459772bad15db29"], "craftTimeSeconds": 360 }, - "59faff1d86f7746c51718c9c": { - "rewardTpls": [ - "5c12620d86f7743f8b198b72", - "5c12620d86f7743f8b198b72", - "5e2aedd986f7746d404f3aa4", - "5e2aedd986f7746d404f3aa4" - ], - "craftTimeSeconds": 43260 - }, - "655c663a6689c676ce57af85": { "rewardTpls": ["5c0e655586f774045612eeb2"], "craftTimeSeconds": 3960 }, - "5aa2b986e5b5b00014028f4c": { "rewardTpls": ["62a091170b9d3c46de5b6cf2"], "craftTimeSeconds": 3960 }, - "5c13cd2486f774072c757944": { "rewardTpls": ["62a0a098de7ac8199358053b"], "craftTimeSeconds": 3960 }, - "5a0c27731526d80618476ac4": { - "rewardTpls": ["5d1b392c86f77425243e98fe", "5d1b392c86f77425243e98fe"], - "craftTimeSeconds": 3960 - }, - "5c0530ee86f774697952d952": { "rewardTpls": ["6389c8c5dbfd5e4b95197e6b"], "craftTimeSeconds": 39960 } - }, + "craftTimeOverride": -1, + "directRewards": [ + {"reward": ["5857a8bc2459772bad15db29"], "requiredItems": ["665ee77ccf2d642e98220bca"], "craftTimeSeconds": 360, + "repeatable": false }, + {"reward": ["5c093ca986f7740a1867ab12"], "requiredItems": ["5732ee6a24597719ae0c0281"], "craftTimeSeconds": 3960, + "repeatable": false }, + {"reward": ["655c669103999d3c810c025b"], "requiredItems": ["635267ab3c89e2112001f826"], "craftTimeSeconds": 3960, + "repeatable": false }, + {"reward": ["5fc64ea372b0dd78d51159dc"], "requiredItems": ["635267ab3c89e2112001f826", "635267ab3c89e2112001f826", + "635267ab3c89e2112001f826", "635267ab3c89e2112001f826", "635267ab3c89e2112001f826"], "craftTimeSeconds": 3960, + "repeatable": false }, + {"reward": ["5c0e874186f7745dc7616606", "5c0e842486f77443a74d2976"], "requiredItems": ["66572c82ad599021091c6118"], + "craftTimeSeconds": 3960, "repeatable": false }, + {"reward": ["60a7ad3a0c5cb24b0134664a", "60a7ad2a2198820d95707a2e"], "requiredItems": ["66572cbdad599021091c611a"], + "craftTimeSeconds": 3960, "repeatable": false }, + {"reward": ["5b3b713c5acfc4330140bd8d"], "requiredItems": ["66572be36a723f7f005a066e"], "craftTimeSeconds": 3960, + "repeatable": false }, + {"reward": ["62a09dd4621468534a797ac7", "5bc9b9ecd4351e3bac122519"], "requiredItems": ["66572b8d80b1cd4b6a67847f"], + "craftTimeSeconds": 3960, "repeatable": false }, + {"reward": ["5d40407c86f774318526545a", "5d40407c86f774318526545a", "5d40407c86f774318526545a"], "requiredItems": ["655c66e40b2de553b618d4b8"], + "craftTimeSeconds": 3960, "repeatable": false }, + {"reward": ["572b7adb24597762ae139821", "56e335e4d2720b6c058b456d"], "requiredItems": ["655c673673a43e23e857aebd"], + "craftTimeSeconds": 3960, "repeatable": false }, + {"reward": ["637b60c3b7afa97bfc3d7001", "59e3577886f774176a362503"], "requiredItems": ["655c67782a1356436041c9c5"], + "craftTimeSeconds": 3960, "repeatable": false }, + {"reward": ["590c657e86f77412b013051d"], "requiredItems": ["655c652d60d0ac437100fed7"], "craftTimeSeconds": 3960, + "repeatable": false }, + {"reward": ["5c0e655586f774045612eeb2"], "requiredItems": ["655c663a6689c676ce57af85"], "craftTimeSeconds": 3960, + "repeatable": false }, + {"reward": ["5d1b376e86f774252519444e"], "requiredItems": ["6582dbf0b8d7830efc45016f"], "craftTimeSeconds": 3960, + "repeatable": true }, + {"reward": ["62a091170b9d3c46de5b6cf2"], "requiredItems": ["5aa2b986e5b5b00014028f4c"], "craftTimeSeconds": 3960, + "repeatable": true }, + {"reward": ["62a0a098de7ac8199358053b"], "requiredItems": ["5c13cd2486f774072c757944"], "craftTimeSeconds": 3960, + "repeatable": true }, + {"reward": ["5d1b392c86f77425243e98fe", "5d1b392c86f77425243e98fe"], "requiredItems": ["5a0c27731526d80618476ac4"], + "craftTimeSeconds": 3960, "repeatable": true }, + {"reward": ["5e2aedd986f7746d404f3aa4", "5e2aedd986f7746d404f3aa4", "5c12620d86f7743f8b198b72", "5c12620d86f7743f8b198b72"], + "requiredItems": ["59faff1d86f7746c51718c9c"], "craftTimeSeconds": 39960, "repeatable": true }, + {"reward": ["6389c8c5dbfd5e4b95197e6b"], "requiredItems": ["5c0530ee86f774697952d952"], "craftTimeSeconds": 39960, + "repeatable": true } + ], "directRewardStackSize": { "exampleParentId": { "min": 1000, "max": 50000 } }, diff --git a/project/src/controllers/GameController.ts b/project/src/controllers/GameController.ts index 544ac767..a66216f6 100644 --- a/project/src/controllers/GameController.ts +++ b/project/src/controllers/GameController.ts @@ -117,6 +117,11 @@ export class GameController { fullProfile.spt.migrations = {}; } + // Track one time use cultist rewards + if (typeof fullProfile.spt.cultistRewards === "undefined") { + fullProfile.spt.cultistRewards = new Map(); + } + //3.9 migrations if (fullProfile.spt.version.includes("3.9.") && !fullProfile.spt.migrations["39x"]) { // Check every item has a valid mongoid diff --git a/project/src/generators/LocationLootGenerator.ts b/project/src/generators/LocationLootGenerator.ts index c40b71fb..c7776660 100644 --- a/project/src/generators/LocationLootGenerator.ts +++ b/project/src/generators/LocationLootGenerator.ts @@ -847,6 +847,9 @@ export class LocationLootGenerator { throw new Error(`Item for tpl ${chosenComposedKey} was not found in the spawn point`); } const itemTemplate = this.itemHelper.getItem(chosenTpl)[1]; + if (!itemTemplate) { + this.logger.error(`Item tpl: ${chosenTpl} cannot be found in database`); + } // Item array to return const itemWithMods: IItem[] = []; diff --git a/project/src/models/eft/profile/ISptProfile.ts b/project/src/models/eft/profile/ISptProfile.ts index 1a460b73..97162460 100644 --- a/project/src/models/eft/profile/ISptProfile.ts +++ b/project/src/models/eft/profile/ISptProfile.ts @@ -192,6 +192,14 @@ export interface ISpt { freeRepeatableRefreshUsedCount?: Record; /** When was a profile migrated, value is timestamp */ migrations?: Record; + /** Cultist circle rewards received that are one time use, key (md5) is a combination of sacrificed + reward items */ + cultistRewards?: Map; +} + +export interface IAcceptedCultistReward { + timestamp: number; + sacrificeItems: string[]; + rewardItems: string[]; } export interface IModDetails { diff --git a/project/src/models/spt/config/IHideoutConfig.ts b/project/src/models/spt/config/IHideoutConfig.ts index 2bdeb39b..01349c79 100644 --- a/project/src/models/spt/config/IHideoutConfig.ts +++ b/project/src/models/spt/config/IHideoutConfig.ts @@ -20,11 +20,21 @@ export interface ICultistCircleSettings { maxRewardItemCount: number; maxAttemptsToPickRewardsWithinBudget: number; rewardPriceMultiplerMinMax: MinMax; + /** The odds that meeting the highest threshold gives you a bonus to crafting time */ + bonusAmountMultiplier: number; + bonusChanceMultiplier: number; + /** What is considered a "high-value" item */ + highValueThresholdRub: number; + /** Hideout/task reward crafts have a unique craft time */ + hideoutTaskRewardTimeSeconds: number; + /** Rouble amount player needs to sacrifice to get chance of hideout/task rewards */ + hideoutCraftSacrificeThresholdRub: number; craftTimeThreshholds: ICraftTimeThreshhold[]; - /** -1 means no override */ + /** -1 means no override, value in seconds */ craftTimeOverride: number; - /** Specific reward pool when player sacrificed one specific item */ - directRewards: Record; + /** Specific reward pool when player sacrifice specific item(s) */ + directRewards: IDirectRewardSettings[]; + /** Overrides for reward stack sizes, keyed by item tpl */ directRewardStackSize: Record; /** Item tpls to exclude from the reward pool */ rewardItemBlacklist: string[]; @@ -38,6 +48,9 @@ export interface ICraftTimeThreshhold extends MinMax { } export interface IDirectRewardSettings { - rewardTpls: string[]; + reward: string[]; + requiredItems: string[]; craftTimeSeconds: number; + /** Is the reward a one time reward or can it be given multiple times */ + repeatable: boolean; } diff --git a/project/src/services/CircleOfCultistService.ts b/project/src/services/CircleOfCultistService.ts index 2eea8082..3e6d39eb 100644 --- a/project/src/services/CircleOfCultistService.ts +++ b/project/src/services/CircleOfCultistService.ts @@ -3,24 +3,27 @@ import { InventoryHelper } from "@spt/helpers/InventoryHelper"; import { ItemHelper } from "@spt/helpers/ItemHelper"; import { PresetHelper } from "@spt/helpers/PresetHelper"; import { ProfileHelper } from "@spt/helpers/ProfileHelper"; +import { QuestHelper } from "@spt/helpers/QuestHelper"; import { IPmcData } from "@spt/models/eft/common/IPmcData"; import { IBotHideoutArea } from "@spt/models/eft/common/tables/IBotBase"; import { IItem } from "@spt/models/eft/common/tables/IItem"; import { IStageRequirement } from "@spt/models/eft/hideout/IHideoutArea"; import { IHideoutCircleOfCultistProductionStartRequestData } from "@spt/models/eft/hideout/IHideoutCircleOfCultistProductionStartRequestData"; -import { - IHideoutProduction, - IHideoutProductionData, - IRequirement, - IRequirementBase, -} from "@spt/models/eft/hideout/IHideoutProduction"; +import { IRequirement, IRequirementBase } from "@spt/models/eft/hideout/IHideoutProduction"; import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse"; +import { IAcceptedCultistReward } from "@spt/models/eft/profile/ISptProfile"; import { BaseClasses } from "@spt/models/enums/BaseClasses"; import { ConfigTypes } from "@spt/models/enums/ConfigTypes"; import { HideoutAreas } from "@spt/models/enums/HideoutAreas"; import { ItemTpl } from "@spt/models/enums/ItemTpl"; +import { QuestStatus } from "@spt/models/enums/QuestStatus"; import { SkillTypes } from "@spt/models/enums/SkillTypes"; -import { IDirectRewardSettings, IHideoutConfig } from "@spt/models/spt/config/IHideoutConfig"; +import { + ICraftTimeThreshhold, + ICultistCircleSettings, + IDirectRewardSettings, + IHideoutConfig, +} from "@spt/models/spt/config/IHideoutConfig"; import { ILogger } from "@spt/models/spt/utils/ILogger"; import { EventOutputHolder } from "@spt/routers/EventOutputHolder"; import { ConfigServer } from "@spt/servers/ConfigServer"; @@ -50,6 +53,7 @@ export class CircleOfCultistService { @inject("ProfileHelper") protected profileHelper: ProfileHelper, @inject("InventoryHelper") protected inventoryHelper: InventoryHelper, @inject("HideoutHelper") protected hideoutHelper: HideoutHelper, + @inject("QuestHelper") protected questHelper: QuestHelper, @inject("DatabaseService") protected databaseService: DatabaseService, @inject("ItemFilterService") protected itemFilterService: ItemFilterService, @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, @@ -74,7 +78,7 @@ export class CircleOfCultistService { ): IItemEventRouterResponse { const cultistCircleStashId = pmcData.Inventory.hideoutAreaStashes[HideoutAreas.CIRCLE_OF_CULTISTS]; - // Sparse, just has id + // `cultistRecipes` just has single recipeId const cultistCraftData = this.databaseService.getHideout().production.cultistRecipes[0]; const sacrificedItems: IItem[] = this.getSacrificedItems(pmcData); const sacrificedItemCostRoubles = sacrificedItems.reduce( @@ -82,24 +86,22 @@ export class CircleOfCultistService { 0, ); - // Get a randomised value to multiply the sacrificed rouble cost by - let rewardAmountMultiplier = this.randomUtil.getFloat( - this.hideoutConfig.cultistCircle.rewardPriceMultiplerMinMax.min, - this.hideoutConfig.cultistCircle.rewardPriceMultiplerMinMax.max, - ); - - // Adjust the above value generated by the players hideout mgmt skill - const hideoutManagementSkill = this.profileHelper.getSkillFromProfile(pmcData, SkillTypes.HIDEOUT_MANAGEMENT); - if (hideoutManagementSkill) { - rewardAmountMultiplier *= 1 + hideoutManagementSkill.Progress / 10000; // 5100 becomes 0.51, add 1 to it, 1.51, multiply the bonus by it (e.g. 1.2 x 1.51) - } + const rewardAmountMultiplier = this.getRewardAmountMultipler(pmcData, this.hideoutConfig.cultistCircle); // Get the rouble amount we generate rewards with from cost of sacrified items * above multipler - const rewardAmountRoubles = sacrificedItemCostRoubles * rewardAmountMultiplier; + const rewardAmountRoubles = Math.round(sacrificedItemCostRoubles * rewardAmountMultiplier); - // Has player sacrified a single item in directReward dict - const directRewardSettings = this.hideoutConfig.cultistCircle.directRewards[sacrificedItems[0]._tpl]; - const hasSacrificedSingleItemFlaggedInConfig = sacrificedItems.length === 1 && !!directRewardSettings; + // Check if it matches any direct swap recipes + const directRewardsCache = this.generateSacrificedItemsCache(this.hideoutConfig.cultistCircle.directRewards); + const directRewardSettings = this.checkForDirectReward(sessionId, sacrificedItems, directRewardsCache); + const hasDirectReward = directRewardSettings?.reward.length > 0; + + // Get craft time and bonus status + const craftingInfo = this.getCircleCraftingInfo( + rewardAmountRoubles, + this.hideoutConfig.cultistCircle, + directRewardSettings, + ); // Create production in pmc profile this.registerCircleOfCultistProduction( @@ -107,31 +109,31 @@ export class CircleOfCultistService { pmcData, cultistCraftData._id, sacrificedItems, - rewardAmountRoubles, - directRewardSettings, + craftingInfo.time, ); const output = this.eventOutputHolder.getOutput(sessionId); - // Remove sacrified items + // Remove sacrificed items from circle inventory for (const item of sacrificedItems) { if (item.slotId === CircleOfCultistService.circleOfCultistSlotId) { this.inventoryHelper.removeItem(pmcData, item._id, sessionId, output); } } - let rewards: IItem[][]; - if (hasSacrificedSingleItemFlaggedInConfig) { - rewards = this.getExplicitRewards(directRewardSettings, cultistCircleStashId); - } else { - const rewardItemPool = this.getCultistCircleRewardPool(sessionId, pmcData); - rewards = this.getRewardsWithinBudget(rewardItemPool, rewardAmountRoubles, cultistCircleStashId); - } + const rewards = hasDirectReward + ? this.getDirectRewards(sessionId, directRewardSettings, cultistCircleStashId) + : this.getRewardsWithinBudget( + this.getCultistCircleRewardPool(sessionId, pmcData, craftingInfo, this.hideoutConfig.cultistCircle), + rewardAmountRoubles, + cultistCircleStashId, + this.hideoutConfig.cultistCircle, + ); // Get the container grid for cultist stash area const cultistStashDbItem = this.itemHelper.getItem(ItemTpl.HIDEOUTAREACONTAINER_CIRCLEOFCULTISTS_STASH_1); - // Ensure items fit into container + // Ensure rewards fit into container const containerGrid = this.inventoryHelper.getContainerSlotMap(cultistStashDbItem[1]._id); const canAddToContainer = this.inventoryHelper.canPlaceItemsInContainer( this.cloner.clone(containerGrid), // MUST clone grid before passing in as function modifies grid @@ -146,7 +148,6 @@ export class CircleOfCultistService { cultistCircleStashId, CircleOfCultistService.circleOfCultistSlotId, ); - // Add item + mods to output and profile inventory output.profileChanges[sessionId].items.new.push(...itemToAdd); pmcData.Inventory.items.push(...itemToAdd); @@ -160,29 +161,60 @@ export class CircleOfCultistService { return output; } + /** + * Create a map of the possible direct rewards, keyed by the items needed to be sacrificed + * @param directRewards Direct rewards array from hideout config + * @returns Map + */ + protected generateSacrificedItemsCache(directRewards: IDirectRewardSettings[]): Map { + const result = new Map(); + for (const rewardSettings of directRewards) { + const key = this.hashUtil.generateMd5ForData(rewardSettings.requiredItems.sort().join(",")); + result.set(key, rewardSettings); + } + + return result; + } + + /** + * Get the reward amount multiple value based on players hideout management skill + configs rewardPriceMultiplerMinMax values + * @param pmcData Player profile + * @param cultistCircleSettings Circle config settings + * @returns Reward Amount Multipler + */ + protected getRewardAmountMultipler(pmcData: IPmcData, cultistCircleSettings: ICultistCircleSettings): number { + // Get a randomised value to multiply the sacrificed rouble cost by + let rewardAmountMultiplier = this.randomUtil.getFloat( + cultistCircleSettings.rewardPriceMultiplerMinMax.min, + cultistCircleSettings.rewardPriceMultiplerMinMax.max, + ); + + // Adjust value generated by the players hideout management skill + const hideoutManagementSkill = this.profileHelper.getSkillFromProfile(pmcData, SkillTypes.HIDEOUT_MANAGEMENT); + if (hideoutManagementSkill) { + rewardAmountMultiplier *= 1 + hideoutManagementSkill.Progress / 10000; // 5100 becomes 0.51, add 1 to it, 1.51, multiply the bonus by it (e.g. 1.2 x 1.51) + } + + return rewardAmountMultiplier; + } + /** * Register production inside player profile * @param sessionId Session id * @param pmcData Player profile * @param recipeId Recipe id * @param sacrificedItems Items player sacrificed - * @param rewardAmountRoubles Rouble amount to reward player in items with - * @param directRewardSettings OPTIONAL: If craft is giving direct rewards + * @param craftingTime How long the ritual should take */ protected registerCircleOfCultistProduction( sessionId: string, pmcData: IPmcData, recipeId: string, sacrificedItems: IItem[], - rewardAmountRoubles: number, - directRewardSettings?: IDirectRewardSettings, + craftingTime: number, ): void { // Create circle production/craft object to add to player profile - const cultistProduction = this.hideoutHelper.initProduction( - recipeId, - this.getCircleCraftTimeSeconds(rewardAmountRoubles, directRewardSettings), - false, - ); + const cultistProduction = this.hideoutHelper.initProduction(recipeId, craftingTime, false); // Flag as cultist circle for code to pick up later cultistProduction.sptIsCultistCircle = true; @@ -196,35 +228,79 @@ export class CircleOfCultistService { /** * Get the circle craft time as seconds, value is based on reward item value - * OR rewards are direct, then use custom craft time defined in oarameter object + * And get the bonus status to determine what tier of reward is given * @param rewardAmountRoubles Value of rewards in roubles - * @param directRewardSettings OPTIONAL: If craft is giving direct rewards - * @returns craft time seconds + * @param circleConfig Circle config values + * @param directRewardSettings OPTIONAL - Values related to direct reward being given + * @returns craft time + type of reward + reward details */ - protected getCircleCraftTimeSeconds( + protected getCircleCraftingInfo( rewardAmountRoubles: number, + circleConfig: ICultistCircleSettings, directRewardSettings?: IDirectRewardSettings, - ): number { - // Edge case, check if override exists - if (this.hideoutConfig.cultistCircle.craftTimeOverride !== -1) { - return this.hideoutConfig.cultistCircle.craftTimeOverride; - } + ): ICraftDetails { + const result = { + time: -1, + rewardType: CircleRewardType.RANDOM, + rewardAmountRoubles: rewardAmountRoubles, + rewardDetails: null, + }; - // Craft is rewarding items directly, use custom craft time + // Direct reward edge case if (directRewardSettings) { - return directRewardSettings.craftTimeSeconds; + result.time = directRewardSettings.craftTimeSeconds; + + return result; } - const thresholds = this.hideoutConfig.cultistCircle.craftTimeThreshholds; + // Get a threshold where sacrificed amount is between thresholds min and max + const matchingThreshold = this.getMatchingThreshold(circleConfig.craftTimeThreshholds, rewardAmountRoubles); + if ( + rewardAmountRoubles >= circleConfig.hideoutCraftSacrificeThresholdRub && + Math.random() <= circleConfig.bonusChanceMultiplier + ) { + // Sacrifice amount is enough + passed 25% check to get hideout/task rewards + result.time = + circleConfig.craftTimeOverride !== -1 + ? circleConfig.craftTimeOverride + : circleConfig.hideoutTaskRewardTimeSeconds; + result.rewardType = CircleRewardType.HIDEOUT_TASK; + + return result; + } + + // Edge case, check if override exists, Otherwise use matching threshold craft time + result.time = + circleConfig.craftTimeOverride !== -1 ? circleConfig.craftTimeOverride : matchingThreshold.craftTimeSeconds; + + result.rewardDetails = matchingThreshold; + + return result; + } + + protected getMatchingThreshold( + thresholds: ICraftTimeThreshhold[], + rewardAmountRoubles: number, + ): ICraftTimeThreshhold { const matchingThreshold = thresholds.find( (craftThreshold) => craftThreshold.min <= rewardAmountRoubles && craftThreshold.max >= rewardAmountRoubles, ); + + // No matching threshold, make one if (!matchingThreshold) { - // No craft time found, default to 12 hours - return this.timeUtil.getHoursAsSeconds(12); + // None found, use a defalt + this.logger.warning("Unable to find a matching cultist circle threshold, using fallback of 12 hours"); + + // Use first threshold value (cheapest) from parameter array, otherwise use 12 hours + const firstThreshold = thresholds[0]; + const craftTime = firstThreshold?.craftTimeSeconds + ? firstThreshold.craftTimeSeconds + : this.timeUtil.getHoursAsSeconds(12); + + return { min: firstThreshold?.min ?? 1, max: firstThreshold?.max ?? 34999, craftTimeSeconds: craftTime }; } - return matchingThreshold.craftTimeSeconds; + return matchingThreshold; } /** @@ -262,6 +338,7 @@ export class CircleOfCultistService { rewardItemTplPool: string[], rewardBudget: number, cultistCircleStashId: string, + circleConfig: ICultistCircleSettings, ): IItem[][] { // Prep rewards array (reward can be item with children, hence array of arrays) const rewards: IItem[][] = []; @@ -273,9 +350,9 @@ export class CircleOfCultistService { while ( totalRewardCost < rewardBudget && rewardItemTplPool.length > 0 && - rewardItemCount < this.hideoutConfig.cultistCircle.maxRewardItemCount + rewardItemCount < circleConfig.maxRewardItemCount ) { - if (failedAttempts > this.hideoutConfig.cultistCircle.maxAttemptsToPickRewardsWithinBudget) { + if (failedAttempts > circleConfig.maxAttemptsToPickRewardsWithinBudget) { this.logger.warning(`Exiting reward generation after ${failedAttempts} failed attempts`); break; @@ -340,46 +417,31 @@ export class CircleOfCultistService { } /** - * Give every item as a reward that's passed in - * @param rewardTpls Item tpls to turn into reward items + * Get direct rewards + * @param sessionId sessionId + * @param directReward Items sacrificed * @param cultistCircleStashId Id of stash item - * @returns Array of item arrays + * @returns The reward object */ - protected getExplicitRewards( - explicitRewardSettings: IDirectRewardSettings, + protected getDirectRewards( + sessionId: string, + directReward: IDirectRewardSettings, cultistCircleStashId: string, ): IItem[][] { // Prep rewards array (reward can be item with children, hence array of arrays) const rewards: IItem[][] = []; - for (const rewardTpl of explicitRewardSettings.rewardTpls) { - if ( - this.itemHelper.armorItemHasRemovableOrSoftInsertSlots(rewardTpl) || - this.itemHelper.isOfBaseclass(rewardTpl, BaseClasses.WEAPON) - ) { - const defaultPreset = this.presetHelper.getDefaultPreset(rewardTpl); - if (!defaultPreset) { - this.logger.warning(`Reward tpl: ${rewardTpl} lacks a default preset, skipping reward`); - continue; - } + // Handle special case of tagilla helmets - only one reward is allowed + if (directReward.reward.includes(ItemTpl.FACECOVER_TAGILLAS_WELDING_MASK_GORILLA)) { + directReward.reward = [this.randomUtil.getArrayValue(directReward.reward)]; + } - // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory - const presetAndMods: IItem[] = this.itemHelper.replaceIDs(defaultPreset._items); - - this.itemHelper.remapRootItemId(presetAndMods); - - rewards.push(presetAndMods); - - continue; - } - - // Some items can have variable stack size, e.g. ammo - const stackSize = this.getExplicitRewardBaseTypeStackSize(rewardTpl); - - // Not a weapon/armor, standard single item + // Loop because these can include multiple rewards + for (const reward of directReward.reward) { + const stackSize = this.getDirectRewardBaseTypeStackSize(reward); const rewardItem: IItem = { _id: this.hashUtil.generate(), - _tpl: rewardTpl, + _tpl: reward, parentId: cultistCircleStashId, slotId: CircleOfCultistService.circleOfCultistSlotId, upd: { @@ -387,19 +449,67 @@ export class CircleOfCultistService { SpawnedInSession: true, }, }; - rewards.push([rewardItem]); } + // Direct reward is not repeatable, flag collected in profile + if (!directReward.repeatable) { + this.flagDirectRewardAsAcceptedInProfile(sessionId, directReward); + } return rewards; } + /** + * Check for direct rewards from what player sacrificed + * @param sessionId sessionId + * @param sacrificedItems Items sacrificed + * @returns Direct reward items to send to player + */ + protected checkForDirectReward( + sessionId: string, + sacrificedItems: IItem[], + directRewardsCache: Map, + ): IDirectRewardSettings { + // Get sacrificed tpls + const sacrificedItemTpls = sacrificedItems.map((item) => item._tpl); + + // Create md5 key of the items player sacrificed so we can compare against the direct reward cache + const sacrificedItemsKey = this.hashUtil.generateMd5ForData(sacrificedItemTpls.sort().join(",")); + + const matchingDirectReward = directRewardsCache.get(sacrificedItemsKey); + if (!matchingDirectReward) { + // No direct reward + return null; + } + + const fullProfile = this.profileHelper.getFullProfile(sessionId); + const directRewardHash = this.getDirectRewardHashKey(matchingDirectReward); + if (fullProfile.spt.cultistRewards?.has(directRewardHash)) { + // Player has already received this direct reward + return null; + } + + return matchingDirectReward; + } + + /** + * Create an md5 key of the sacrificed + reward items + * @param directReward Direct reward to create key for + * @returns Key + */ + protected getDirectRewardHashKey(directReward: IDirectRewardSettings): string { + // Key is sacrificed items separated by commas, a dash, then the rewards separated by commas + const key = `{${directReward.requiredItems.sort().join(",")}-${directReward.reward.sort().join(",")}`; + + return this.hashUtil.generateMd5ForData(key); + } + /** * Explicit rewards have thier own stack sizes as they dont use a reward rouble pool * @param rewardTpl Item being rewarded to get stack size of * @returns stack size of item */ - protected getExplicitRewardBaseTypeStackSize(rewardTpl: string) { + protected getDirectRewardBaseTypeStackSize(rewardTpl: string): number { const itemDetails = this.itemHelper.getItem(rewardTpl); if (!itemDetails[0]) { this.logger.warning(`${rewardTpl} is not an item, setting stack size to 1`); @@ -416,6 +526,22 @@ export class CircleOfCultistService { return this.randomUtil.getInt(settings.min, settings.max); } + /** + * Add a record to the players profile to signal they have accepted a non-repeatable direct reward + * @param sessionId Session id + * @param directReward Reward sent to player + */ + protected flagDirectRewardAsAcceptedInProfile(sessionId: string, directReward: IDirectRewardSettings) { + const fullProfile = this.profileHelper.getFullProfile(sessionId); + const dataToStoreInProfile: IAcceptedCultistReward = { + timestamp: this.timeUtil.getTimestamp(), + sacrificeItems: directReward.requiredItems, + rewardItems: directReward.reward, + }; + + fullProfile.spt.cultistRewards.set(this.getDirectRewardHashKey(directReward), dataToStoreInProfile); + } + /** * Get the size of a reward items stack * 1 for everything except ammo, ammo can be between min stack and max stack @@ -455,72 +581,87 @@ export class CircleOfCultistService { * Get a pool of tpl IDs of items the player needs to complete hideout crafts/upgrade areas * @param sessionId Session id * @param pmcData Profile of player who will be getting the rewards + * @param rewardType Do we return bonus items (hideout/task items) + * @param cultistCircleConfig Circle config * @returns Array of tpls */ - protected getCultistCircleRewardPool(sessionId: string, pmcData: IPmcData): string[] { + protected getCultistCircleRewardPool( + sessionId: string, + pmcData: IPmcData, + craftingInfo: ICraftDetails, + cultistCircleConfig: ICultistCircleSettings, + ): string[] { const rewardPool = new Set(); - const cultistCircleConfig = this.hideoutConfig.cultistCircle; const hideoutDbData = this.databaseService.getHideout(); - // Merge reward item blacklist with cultist circle blacklist from config + // Merge reward item blacklist and boss item blacklist with cultist circle blacklist from config const itemRewardBlacklist = [ ...this.seasonalEventService.getInactiveSeasonalEventItems(), ...this.itemFilterService.getItemRewardBlacklist(), ...cultistCircleConfig.rewardItemBlacklist, ]; - // What does player need to upgrade hideout areas - const dbAreas = hideoutDbData.areas; - for (const area of this.getPlayerAccessibleHideoutAreas(pmcData.Hideout.Areas)) { - const currentStageLevel = area.level; - const areaType = area.type; + // Hideout and task rewards are ONLY if the bonus is active + switch (craftingInfo.rewardType) { + case CircleRewardType.RANDOM: { + // Just random items so we'll add maxRewardItemCount * 2 amount of random things - // Get next stage of area - const dbArea = dbAreas.find((area) => area.type === areaType); - const nextStageDbData = dbArea.stages[currentStageLevel + 1]; - if (nextStageDbData) { - // Next stage exists, gather up requirements and add to pool - const itemRequirements = this.getItemRequirements(nextStageDbData.requirements); - for (const rewardToAdd of itemRequirements) { - if (itemRewardBlacklist.includes(rewardToAdd.templateId)) { - continue; - } - - rewardPool.add(rewardToAdd.templateId); - } + // Does reward pass the high value threshold + const isHighValueReward = craftingInfo.rewardAmountRoubles >= cultistCircleConfig.highValueThresholdRub; + this.getRandomLoot(rewardPool, itemRewardBlacklist, isHighValueReward); + break; } - } - - // What does player need to start crafts with - const playerUnlockedRecipes = pmcData.UnlockedInfo?.unlockedProductionRecipe ?? []; - const allRecipes = hideoutDbData.production; - for (const recipe of this.getPlayerAccessibleRecipes(playerUnlockedRecipes, allRecipes)) { - const itemRequirements = this.getItemRequirements(recipe.requirements); - for (const requirement of itemRequirements) { - if (itemRewardBlacklist.includes(requirement.templateId)) { - continue; - } - - rewardPool.add(requirement.templateId); - } - } - - // Check for scav case unlock in profile - const hasScavCaseAreaUnlocked = pmcData.Hideout.Areas[HideoutAreas.SCAV_CASE]?.level > 0; - if (hasScavCaseAreaUnlocked) { - // Gather up items used to start scav case crafts - const scavCaseCrafts = hideoutDbData.production.scavRecipes; - for (const craft of scavCaseCrafts) { - // Find the item requirements from each craft - const itemRequirements = this.getItemRequirements(craft.requirements); - for (const requirement of itemRequirements) { - if (itemRewardBlacklist.includes(requirement.templateId)) { - continue; + case CircleRewardType.HIDEOUT_TASK: { + // Hideout/Task loot + // Add hideout upgrade requirements + const dbAreas = hideoutDbData.areas; + for (const area of this.getPlayerAccessibleHideoutAreas(pmcData.Hideout.Areas)) { + const currentStageLevel = area.level; + const areaType = area.type; + // Get next stage of area + const dbArea = dbAreas.find((area) => area.type === areaType); + const nextStageDbData = dbArea.stages[currentStageLevel + 1]; + if (nextStageDbData) { + // Next stage exists, gather up requirements and add to pool + const itemRequirements = this.getItemRequirements(nextStageDbData.requirements); + for (const rewardToAdd of itemRequirements) { + if ( + itemRewardBlacklist.includes(rewardToAdd.templateId) || + !this.itemHelper.isValidItem(rewardToAdd.templateId) + ) { + continue; + } + this.logger.debug( + `Added Hideout Loot: ${this.itemHelper.getItemName(rewardToAdd.templateId)}`, + ); + rewardPool.add(rewardToAdd.templateId); + } } - - // Add tpl to reward pool - rewardPool.add(requirement.templateId); } + + // Add task/quest items + const activeTasks = pmcData.Quests.filter((quest) => quest.status === QuestStatus.Started); + for (const task of activeTasks) { + const questData = this.questHelper.getQuestFromDb(task.qid, pmcData); + const handoverConditions = questData.conditions.AvailableForFinish.filter( + (c) => c.conditionType === "HandoverItem", + ); + for (const condition of handoverConditions) { + for (const neededItem of condition.target) { + if (itemRewardBlacklist.includes(neededItem) || !this.itemHelper.isValidItem(neededItem)) { + continue; + } + this.logger.debug(`Added Task Loot: ${this.itemHelper.getItemName(neededItem)}`); + rewardPool.add(neededItem); + } + } + } + + // If we have no tasks or hideout stuff left or need more loot to fill it out, default to high value + if (rewardPool.size < cultistCircleConfig.maxRewardItemCount + 2) { + this.getRandomLoot(rewardPool, itemRewardBlacklist, true); + } + break; } } @@ -556,17 +697,45 @@ export class CircleOfCultistService { } /** - * Get all recipes the player has access to, includes base + unlocked recipes - * @param unlockedRecipes Recipes player has flagged as unlocked - * @param allRecipes All recipes - * @returns Array of recipes + * Get array of random reward items + * @param rewardPool Reward pool to add to + * @param itemRewardBlacklist Reward Blacklist + * @param valuable Should these items meet the valuable threshold + * @returns rewardPool */ - protected getPlayerAccessibleRecipes( - unlockedRecipes: string[], - allRecipes: IHideoutProductionData, - ): IHideoutProduction[] { - // Get default unlocked recipes + locked recipes they've unlocked - return allRecipes.recipes.filter((recipe) => !recipe.locked || unlockedRecipes.includes(recipe._id)); + protected getRandomLoot(rewardPool: Set, itemRewardBlacklist: string[], valuable: boolean): Set { + const allItems = this.itemHelper.getItems(); + let currentItemCount = 0; + let attempts = 0; + // currentItemCount var will look for the correct number of items, attempts var will keep this from never stopping if the highValueThreshold is too high + while ( + currentItemCount < this.hideoutConfig.cultistCircle.maxRewardItemCount + 2 && + attempts < allItems.length + ) { + attempts++; + const randomItem = this.randomUtil.getArrayValue(allItems); + if ( + itemRewardBlacklist.includes(randomItem._id) || + BaseClasses.AMMO === randomItem._parent || + BaseClasses.MONEY === randomItem._parent || + !this.itemHelper.isValidItem(randomItem._id) + ) { + continue; + } + + // Valuable check + if (valuable) { + const itemValue = this.itemHelper.getItemMaxPrice(randomItem._id); + if (itemValue < this.hideoutConfig.cultistCircle.highValueThresholdRub) { + this.logger.debug(`Ignored due to value: ${this.itemHelper.getItemName(randomItem._id)}`); + continue; + } + } + this.logger.debug(`Added: ${this.itemHelper.getItemName(randomItem._id)}`); + rewardPool.add(randomItem._id); + currentItemCount++; + } + return rewardPool; } /** @@ -578,3 +747,15 @@ export class CircleOfCultistService { return requirements.filter((requirement) => requirement.type === "Item"); } } + +export enum CircleRewardType { + RANDOM = 0, + HIDEOUT_TASK = 1, +} + +export interface ICraftDetails { + time: number; + rewardType: CircleRewardType; + rewardAmountRoubles: number; + rewardDetails?: ICraftTimeThreshhold; +}