0
0
mirror of https://github.com/sp-tarkov/server.git synced 2025-02-12 16:10:43 -05:00

Cultist circle improvements (#973)

Brings Cultist Cicle rewards closer to live behavior.

---------

Co-authored-by: Bob S <shibdib@users.noreply.github.com>
Co-authored-by: Chomp <dev@dev.sp-tarkov.com>
This commit is contained in:
Chomp 2024-12-06 17:15:06 +00:00 committed by GitHub
parent d34eca32bb
commit d2b7baa8b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 424 additions and 212 deletions

View File

@ -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 }
},

View File

@ -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

View File

@ -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[] = [];

View File

@ -192,6 +192,14 @@ export interface ISpt {
freeRepeatableRefreshUsedCount?: Record<string, number>;
/** When was a profile migrated, value is timestamp */
migrations?: Record<string, number>;
/** Cultist circle rewards received that are one time use, key (md5) is a combination of sacrificed + reward items */
cultistRewards?: Map<string, IAcceptedCultistReward>;
}
export interface IAcceptedCultistReward {
timestamp: number;
sacrificeItems: string[];
rewardItems: string[];
}
export interface IModDetails {

View File

@ -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<string, IDirectRewardSettings>;
/** Specific reward pool when player sacrifice specific item(s) */
directRewards: IDirectRewardSettings[];
/** Overrides for reward stack sizes, keyed by item tpl */
directRewardStackSize: Record<string, MinMax>;
/** 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;
}

View File

@ -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<string, IDirectRewardSettings> {
const result = new Map<string, IDirectRewardSettings>();
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<string, IDirectRewardSettings>,
): 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<string>();
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<string>, itemRewardBlacklist: string[], valuable: boolean): Set<string> {
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;
}