From 98b19ff1db20e289301ff8885325c180d1517b36 Mon Sep 17 00:00:00 2001 From: Dev Date: Mon, 26 Aug 2024 13:41:59 +0100 Subject: [PATCH] Added `cultistCircle` config section to `HideoutConfig` and made use of values --- project/assets/configs/hideout.json | 10 +- .../src/models/spt/config/IHideoutConfig.ts | 8 + .../src/services/CircleOfCultistService.ts | 313 ++++++++++++++++++ 3 files changed, 330 insertions(+), 1 deletion(-) create mode 100644 project/src/services/CircleOfCultistService.ts diff --git a/project/assets/configs/hideout.json b/project/assets/configs/hideout.json index 161ce9b3..4d5b1ec2 100644 --- a/project/assets/configs/hideout.json +++ b/project/assets/configs/hideout.json @@ -8,5 +8,13 @@ "expCraftAmount": 10, "overrideCraftTimeSeconds": -1, "overrideBuildTimeSeconds": -1, - "updateProfileHideoutWhenActiveWithinMinutes": 90 + "updateProfileHideoutWhenActiveWithinMinutes": 90, + "cultistCircle": { + "maxRewardItemCount": 5, + "maxAttemptsToPickRewardsWithinBudget": 5, + "rewardPriceMultiplerMinMax": { + "min": 0.7, + "max": 1.4 + } + } } diff --git a/project/src/models/spt/config/IHideoutConfig.ts b/project/src/models/spt/config/IHideoutConfig.ts index 1e4039a9..648b2b4a 100644 --- a/project/src/models/spt/config/IHideoutConfig.ts +++ b/project/src/models/spt/config/IHideoutConfig.ts @@ -1,3 +1,4 @@ +import { MinMax } from "@spt/models/common/MinMax"; import { IBaseConfig, IRunIntervalValues } from "@spt/models/spt/config/IBaseConfig"; export interface IHideoutConfig extends IBaseConfig { @@ -12,4 +13,11 @@ export interface IHideoutConfig extends IBaseConfig { overrideBuildTimeSeconds: number; /** Only process a profiles hideout crafts when it has been active in the last x minutes */ updateProfileHideoutWhenActiveWithinMinutes: number; + cultistCircle: ICultistCircleSettings; +} + +export interface ICultistCircleSettings { + maxRewardItemCount: number; + maxAttemptsToPickRewardsWithinBudget: number; + rewardPriceMultiplerMinMax: MinMax; } diff --git a/project/src/services/CircleOfCultistService.ts b/project/src/services/CircleOfCultistService.ts new file mode 100644 index 00000000..b950d755 --- /dev/null +++ b/project/src/services/CircleOfCultistService.ts @@ -0,0 +1,313 @@ +import { HideoutHelper } from "@spt/helpers/HideoutHelper"; +import { InventoryHelper } from "@spt/helpers/InventoryHelper"; +import { ItemHelper } from "@spt/helpers/ItemHelper"; +import { PresetHelper } from "@spt/helpers/PresetHelper"; +import { IPmcData } from "@spt/models/eft/common/IPmcData"; +import { Item } from "@spt/models/eft/common/tables/IItem"; +import { IStageRequirement } from "@spt/models/eft/hideout/IHideoutArea"; +import { IHideoutCircleOfCultistProductionStartRequestData } from "@spt/models/eft/hideout/IHideoutCircleOfCultistProductionStartRequestData"; +import { Requirement } from "@spt/models/eft/hideout/IHideoutProduction"; +import { IItemEventRouterResponse } from "@spt/models/eft/itemEvent/IItemEventRouterResponse"; +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 { 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"; +import { HashUtil } from "@spt/utils/HashUtil"; +import { RandomUtil } from "@spt/utils/RandomUtil"; +import { ICloner } from "@spt/utils/cloners/ICloner"; +import { inject, injectable } from "tsyringe"; +import { DatabaseService } from "./DatabaseService"; + +@injectable() +export class CircleOfCultistService { + protected static circleOfCultistSlotId = "CircleOfCultistsGrid1"; + protected hideoutConfig: IHideoutConfig; + + constructor( + @inject("PrimaryLogger") protected logger: ILogger, + @inject("PrimaryCloner") protected cloner: ICloner, + @inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder, + @inject("RandomUtil") protected randomUtil: RandomUtil, + @inject("HashUtil") protected hashUtil: HashUtil, + @inject("ItemHelper") protected itemHelper: ItemHelper, + @inject("PresetHelper") protected presetHelper: PresetHelper, + @inject("InventoryHelper") protected inventoryHelper: InventoryHelper, + @inject("HideoutHelper") protected hideoutHelper: HideoutHelper, + @inject("DatabaseService") protected databaseService: DatabaseService, + @inject("ConfigServer") protected configServer: ConfigServer, + ) { + this.hideoutConfig = this.configServer.getConfig(ConfigTypes.HIDEOUT); + } + + /** + * Start a sacrifice event + * Generate rewards + * Delete sacrificed items + * @param sessionId Session id + * @param pmcData Player profile doing sacrifice + * @param request Client request + * @returns IItemEventRouterResponse + */ + public startSacrifice( + sessionId: string, + pmcData: IPmcData, + request: IHideoutCircleOfCultistProductionStartRequestData, + ): IItemEventRouterResponse { + const cultistCircleStashId = pmcData.Inventory.hideoutAreaStashes[HideoutAreas.CIRCLE_OF_CULTISTS]; + + // Sparse, just has id + const cultistCraftData = this.databaseService.getHideout().production.cultistRecipes[0]; + const sacrificedItems: Item[] = this.getSacrificedItems(pmcData); + const sacrificedItemCostRoubles = sacrificedItems.reduce( + (sum, curr) => sum + (this.itemHelper.getItemPrice(curr._tpl) ?? 0), + 0, + ); + + // TODO - include hideout management skill to bonus + const rewardAmountMultiplier = this.randomUtil.getFloat( + this.hideoutConfig.cultistCircle.rewardPriceMultiplerMinMax.min, + this.hideoutConfig.cultistCircle.rewardPriceMultiplerMinMax.max, + ); + const rewardAmountRoubles = sacrificedItemCostRoubles * rewardAmountMultiplier; + + // Create production in pmc profile + this.hideoutHelper.registerCircleOfCultistProduction(sessionId, pmcData, cultistCraftData._id, sacrificedItems); + + const output = this.eventOutputHolder.getOutput(sessionId); + + // Remove sacrified items + for (const item of sacrificedItems) { + if (item.slotId === CircleOfCultistService.circleOfCultistSlotId) { + this.inventoryHelper.removeItem(pmcData, item._id, sessionId, output); + } + } + + const rewardItemPool = this.getCultistCircleRewardPool(sessionId, pmcData); + this.logger.warning(`Reward pool item count: ${rewardItemPool.length}`); + + const rewards = this.getRewardsWithinBudget(rewardItemPool, rewardAmountRoubles, cultistCircleStashId); + + // Get the container grid for cultist stash area + const cultistStashDbItem = this.itemHelper.getItem(ItemTpl.HIDEOUTAREACONTAINER_CIRCLEOFCULTISTS_STASH_1); + + // Ensure items 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 + rewards, + ); + + if (canAddToContainer) { + for (const itemToAdd of rewards) { + this.logger.warning(`Placing reward: ${itemToAdd[0]._tpl} in circle grid`); + this.inventoryHelper.placeItemInContainer( + containerGrid, + itemToAdd, + cultistCircleStashId, + CircleOfCultistService.circleOfCultistSlotId, + ); + + // Add item + mods to output and profile inventory + output.profileChanges[sessionId].items.new.push(...itemToAdd); + pmcData.Inventory.items.push(...itemToAdd); + } + } else { + this.logger.error( + `Unable to fit all: ${rewards.length} reward items into sacrifice grid, nothing will be returned`, + ); + } + + return output; + } + + /** + * Get the items player sacrificed in circle + * @param pmcData Player profile + * @returns Array of its from player inventory + */ + protected getSacrificedItems(pmcData: IPmcData): Item[] { + // Get root items that are in the cultist sacrifice window + const inventoryRootItemsInCultistGrid = pmcData.Inventory.items.filter( + (item) => item.slotId === CircleOfCultistService.circleOfCultistSlotId, + ); + + // Get rootitem + its children + const sacrificedItems: Item[] = []; + for (const rootItem of inventoryRootItemsInCultistGrid) { + const rootItemWithChildren = this.itemHelper.findAndReturnChildrenAsItems( + pmcData.Inventory.items, + rootItem._id, + ); + sacrificedItems.push(...rootItemWithChildren); + } + + return sacrificedItems; + } + + /** + * Given a pool of items + rouble budget, pick items until the budget is reached + * @param rewardItemTplPool Items that can be picekd + * @param rewardBudget Rouble budget to reach + * @param cultistCircleStashId Id of stash item + * @returns Array of items + */ + protected getRewardsWithinBudget( + rewardItemTplPool: string[], + rewardBudget: number, + cultistCircleStashId: string, + ): Item[][] { + // Prep rewards array (reward can be item with children, hence array of arrays) + const rewards: Item[][] = []; + + // Pick random rewards until we have exhausted the sacrificed items budget + let totalCost = 0; + let itemsRewardedCount = 0; + let failedAttempts = 0; + while ( + totalCost < rewardBudget && + rewardItemTplPool.length > 0 && + itemsRewardedCount < this.hideoutConfig.cultistCircle.maxRewardItemCount + ) { + if (failedAttempts > this.hideoutConfig.cultistCircle.maxAttemptsToPickRewardsWithinBudget) { + this.logger.warning(`Exiting reward generation after ${failedAttempts} failed attempts`); + + break; + } + + // Choose a random tpl from pool + const randomItemTplFromPool = this.randomUtil.getArrayValue(rewardItemTplPool); + + // Is weapon/armor, handle differently + if ( + this.itemHelper.armorItemHasRemovableOrSoftInsertSlots(randomItemTplFromPool) || + this.itemHelper.isOfBaseclass(randomItemTplFromPool, BaseClasses.WEAPON) + ) { + const defaultPreset = this.presetHelper.getDefaultPreset(randomItemTplFromPool); + if (!defaultPreset) { + this.logger.warning(`Reward tpl: ${randomItemTplFromPool} lacks a default preset, skipping reward`); + failedAttempts++; + + continue; + } + + // Ensure preset has unique ids and is cloned so we don't alter the preset data stored in memory + const presetAndMods: Item[] = this.itemHelper.replaceIDs(defaultPreset._items); + + this.itemHelper.remapRootItemId(presetAndMods); + + rewards.push(presetAndMods); + } + + // Some items can have variable stack size, e.g. ammo + const stackSize = this.getRewardStackSize(randomItemTplFromPool); + + // Not a weapon/armor, standard single item + const rewardItem: Item = { + _id: this.hashUtil.generate(), + _tpl: randomItemTplFromPool, + parentId: cultistCircleStashId, + slotId: CircleOfCultistService.circleOfCultistSlotId, + upd: { + StackObjectsCount: stackSize, + SpawnedInSession: true, + }, + }; + + // Increment price of rewards to give to player and add to reward array + itemsRewardedCount++; + totalCost += this.itemHelper.getItemPrice(randomItemTplFromPool); + rewards.push([rewardItem]); + } + this.logger.warning(`Circle will reward ${itemsRewardedCount} items costing a total of ${totalCost} roubles`); + + return rewards; + } + + /** + * Get the size of a reward items stack + * 1 for everything except ammo, ammo can be between min stack and max stack + * @param itemTpl Item chosen + * @returns Size of stack + */ + protected getRewardStackSize(itemTpl: string) { + if (this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.AMMO)) { + const ammoTemplate = this.itemHelper.getItem(itemTpl)[1]; + return this.itemHelper.getRandomisedAmmoStackSize(ammoTemplate); + } + + return 1; + } + + /** + * Get a pool of tpl IDs of items the player needs to complete hideout crafts/upgrade areas + * @param sessionId Session id + * @param pmcData Player profile + * @returns Array of tpls + */ + protected getCultistCircleRewardPool(sessionId: string, pmcData: IPmcData): string[] { + const rewardPool = new Set(); + + // What does player need to upgrade hideout areas + const dbAreas = this.databaseService.getHideout().areas; + for (const area of 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.getNonFunctionalItemRequirements(nextStageDbData.requirements); + for (const rewardToAdd of itemRequirements) { + rewardPool.add(rewardToAdd.templateId); + } + } + } + + // What does player need to craft items with + const playerUnlockedRecipes = pmcData.UnlockedInfo.unlockedProductionRecipe; + const allRecipes = this.databaseService.getHideout().production; + + // Get default unlocked recipes + locked recipes they've unlocked + const playerAccessibleRecipes = allRecipes.recipes.filter( + (recipe) => !recipe.locked || playerUnlockedRecipes.includes(recipe._id), + ); + for (const recipe of playerAccessibleRecipes) { + const itemRequirements = this.getNonFunctionalItemRequirements(recipe.requirements); + for (const requirement of itemRequirements) { + 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 = this.databaseService.getHideout().scavcase; + for (const craft of scavCaseCrafts) { + // Find the item requirements from each craft + const itemRequirements = this.getNonFunctionalItemRequirements(craft.Requirements); + for (const requirement of itemRequirements) { + // Add tpl to reward pool + rewardPool.add(requirement.templateId); + } + } + } + + return Array.from(rewardPool); + } + + /** + * Iterate over passed in hideout requirements and return the Item + nonfunctional requirements + * @param requirements Requirements to iterate over + * @returns Array of item requirements + */ + protected getNonFunctionalItemRequirements(requirements: IStageRequirement[] | Requirement[]) { + return requirements.filter((requirement) => requirement.type === "Item" && !requirement.isFunctional); + } +}