0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-13 09:50:43 -05:00
server/project/src/services/CircleOfCultistService.ts

580 lines
24 KiB
TypeScript
Raw Normal View History

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 { ProfileHelper } from "@spt/helpers/ProfileHelper";
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 { 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 { SkillTypes } from "@spt/models/enums/SkillTypes";
import { 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";
import { DatabaseService } from "@spt/services/DatabaseService";
import { ItemFilterService } from "@spt/services/ItemFilterService";
import { SeasonalEventService } from "@spt/services/SeasonalEventService";
import { HashUtil } from "@spt/utils/HashUtil";
import { RandomUtil } from "@spt/utils/RandomUtil";
import { TimeUtil } from "@spt/utils/TimeUtil";
import { ICloner } from "@spt/utils/cloners/ICloner";
import { inject, injectable } from "tsyringe";
@injectable()
export class CircleOfCultistService {
protected static circleOfCultistSlotId = "CircleOfCultistsGrid1";
protected hideoutConfig: IHideoutConfig;
constructor(
@inject("PrimaryLogger") protected logger: ILogger,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@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("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("InventoryHelper") protected inventoryHelper: InventoryHelper,
@inject("HideoutHelper") protected hideoutHelper: HideoutHelper,
@inject("DatabaseService") protected databaseService: DatabaseService,
@inject("ItemFilterService") protected itemFilterService: ItemFilterService,
@inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService,
@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: IItem[] = this.getSacrificedItems(pmcData);
const sacrificedItemCostRoubles = sacrificedItems.reduce(
(sum, curr) => sum + (this.itemHelper.getItemPrice(curr._tpl) ?? 0),
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)
}
// Get the rouble amount we generate rewards with from cost of sacrified items * above multipler
const rewardAmountRoubles = 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;
// Create production in pmc profile
this.registerCircleOfCultistProduction(
sessionId,
pmcData,
cultistCraftData._id,
sacrificedItems,
rewardAmountRoubles,
directRewardSettings,
);
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);
}
}
let rewards: IItem[][];
if (hasSacrificedSingleItemFlaggedInConfig) {
rewards = this.getExplicitRewards(directRewardSettings, cultistCircleStashId);
} else {
const rewardItemPool = this.getCultistCircleRewardPool(sessionId, pmcData);
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.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;
}
/**
* 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
*/
protected registerCircleOfCultistProduction(
sessionId: string,
pmcData: IPmcData,
recipeId: string,
sacrificedItems: IItem[],
rewardAmountRoubles: number,
directRewardSettings?: IDirectRewardSettings,
): void {
// Create circle production/craft object to add to player profile
const cultistProduction = this.hideoutHelper.initProduction(
recipeId,
this.getCircleCraftTimeSeconds(rewardAmountRoubles, directRewardSettings),
false,
);
// Flag as cultist circle for code to pick up later
cultistProduction.sptIsCultistCircle = true;
// Add items player sacrificed
cultistProduction.GivenItemsInStart = sacrificedItems;
// Add circle production to profile keyed to recipe id
pmcData.Hideout.Production[recipeId] = cultistProduction;
}
/**
* 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
* @param rewardAmountRoubles Value of rewards in roubles
* @param directRewardSettings OPTIONAL: If craft is giving direct rewards
* @returns craft time seconds
*/
protected getCircleCraftTimeSeconds(
rewardAmountRoubles: number,
directRewardSettings?: IDirectRewardSettings,
): number {
// Edge case, check if override exists
if (this.hideoutConfig.cultistCircle.craftTimeOverride !== -1) {
return this.hideoutConfig.cultistCircle.craftTimeOverride;
}
// Craft is rewarding items directly, use custom craft time
if (directRewardSettings) {
return directRewardSettings.craftTimeSeconds;
}
const thresholds = this.hideoutConfig.cultistCircle.craftTimeThreshholds;
const matchingThreshold = thresholds.find(
(craftThreshold) => craftThreshold.min <= rewardAmountRoubles && craftThreshold.max >= rewardAmountRoubles,
);
if (!matchingThreshold) {
// No craft time found, default to 12 hours
return this.timeUtil.getHoursAsSeconds(12);
}
return matchingThreshold.craftTimeSeconds;
}
/**
* Get the items player sacrificed in circle
* @param pmcData Player profile
* @returns Array of its from player inventory
*/
protected getSacrificedItems(pmcData: IPmcData): IItem[] {
// 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: IItem[] = [];
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 item arrays
*/
protected getRewardsWithinBudget(
rewardItemTplPool: string[],
rewardBudget: number,
cultistCircleStashId: string,
): IItem[][] {
// Prep rewards array (reward can be item with children, hence array of arrays)
const rewards: IItem[][] = [];
// Pick random rewards until we have exhausted the sacrificed items budget
let totalRewardCost = 0;
let rewardItemCount = 0;
let failedAttempts = 0;
while (
totalRewardCost < rewardBudget &&
rewardItemTplPool.length > 0 &&
rewardItemCount < 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: IItem[] = this.itemHelper.replaceIDs(defaultPreset._items);
this.itemHelper.remapRootItemId(presetAndMods);
rewardItemCount++;
totalRewardCost += this.itemHelper.getItemPrice(randomItemTplFromPool);
rewards.push(presetAndMods);
continue;
}
// Some items can have variable stack size, e.g. ammo / currency
const stackSize = this.getRewardStackSize(
randomItemTplFromPool,
rewardBudget / (rewardItemCount === 0 ? 1 : rewardItemCount), // Remaining rouble budget
);
// Not a weapon/armor, standard single item
const rewardItem: IItem = {
_id: this.hashUtil.generate(),
_tpl: randomItemTplFromPool,
parentId: cultistCircleStashId,
slotId: CircleOfCultistService.circleOfCultistSlotId,
upd: {
StackObjectsCount: stackSize,
SpawnedInSession: true,
},
};
// Increment price of rewards to give to player + add to reward array
rewardItemCount++;
const singleItemPrice = this.itemHelper.getItemPrice(randomItemTplFromPool);
const itemPrice = singleItemPrice * stackSize;
totalRewardCost += itemPrice;
rewards.push([rewardItem]);
}
return rewards;
}
/**
* Give every item as a reward that's passed in
* @param rewardTpls Item tpls to turn into reward items
* @param cultistCircleStashId Id of stash item
* @returns Array of item arrays
*/
protected getExplicitRewards(
explicitRewardSettings: 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;
}
// 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
const rewardItem: IItem = {
_id: this.hashUtil.generate(),
_tpl: rewardTpl,
parentId: cultistCircleStashId,
slotId: CircleOfCultistService.circleOfCultistSlotId,
upd: {
StackObjectsCount: stackSize,
SpawnedInSession: true,
},
};
rewards.push([rewardItem]);
}
return rewards;
}
/**
* 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) {
const itemDetails = this.itemHelper.getItem(rewardTpl);
if (!itemDetails[0]) {
this.logger.warning(`${rewardTpl} is not an item, setting stack size to 1`);
return 1;
}
// Look for parent in dict
const settings = this.hideoutConfig.cultistCircle.directRewardStackSize[itemDetails[1]._parent];
if (!settings) {
return 1;
}
return this.randomUtil.getInt(settings.min, settings.max);
}
/**
* 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
* @param rewardPoolRemaining Rouble amount of pool remaining to fill
* @returns Size of stack
*/
protected getRewardStackSize(itemTpl: string, rewardPoolRemaining: number) {
if (this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.AMMO)) {
const ammoTemplate = this.itemHelper.getItem(itemTpl)[1];
return this.itemHelper.getRandomisedAmmoStackSize(ammoTemplate);
}
if (this.itemHelper.isOfBaseclass(itemTpl, BaseClasses.MONEY)) {
// Get currency-specific values from config
const settings = this.hideoutConfig.cultistCircle.currencyRewards[itemTpl];
// What % of the pool remaining should be rewarded as chosen currency
const percentOfPoolToUse = this.randomUtil.getInt(settings.min, settings.max);
// Rouble amount of pool we want to reward as currency
const roubleAmountToFill = this.randomUtil.getPercentOfValue(percentOfPoolToUse, rewardPoolRemaining);
// Convert currency to roubles
const currencyPriceAsRouble = this.itemHelper.getItemPrice(itemTpl);
// How many items can we fit into chosen pool
const itemCountToReward = Math.round(roubleAmountToFill / currencyPriceAsRouble);
return itemCountToReward ?? 1;
}
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 Profile of player who will be getting the rewards
* @returns Array of tpls
*/
protected getCultistCircleRewardPool(sessionId: string, pmcData: IPmcData): string[] {
const rewardPool = new Set<string>();
const cultistCircleConfig = this.hideoutConfig.cultistCircle;
const hideoutDbData = this.databaseService.getHideout();
// Merge reward item blacklist with cultist circle blacklist from config
const itemRewardBlacklist = [
...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;
// 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);
}
}
}
// 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.scavcase;
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;
}
// Add tpl to reward pool
rewardPool.add(requirement.templateId);
}
}
}
// Add custom rewards from config
if (cultistCircleConfig.additionalRewardItemPool.length > 0) {
for (const additionalReward of cultistCircleConfig.additionalRewardItemPool) {
if (itemRewardBlacklist.includes(additionalReward)) {
continue;
}
// Add tpl to reward pool
rewardPool.add(additionalReward);
}
}
return Array.from(rewardPool);
}
/**
* Get all active hideout areas
* @param areas Hideout areas to iterate over
* @returns Active area array
*/
protected getPlayerAccessibleHideoutAreas(areas: IBotHideoutArea[]): IBotHideoutArea[] {
return areas.filter((area) => {
if (area.type === HideoutAreas.CHRISTMAS_TREE && !this.seasonalEventService.christmasEventEnabled()) {
// Christmas tree area and not Christmas, skip
return false;
}
return true;
});
}
/**
* 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
*/
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));
}
/**
* Iterate over passed in hideout requirements and return the Item
* @param requirements Requirements to iterate over
* @returns Array of item requirements
*/
protected getItemRequirements(requirements: IRequirementBase[]): (IStageRequirement | IRequirement)[] {
return requirements.filter((requirement) => requirement.type === "Item");
}
}