Server/project/src/helpers/HideoutHelper.ts
DrakiaXYZ 2adbb6a5fe Properly take and return tools when crafting (!234)
When starting a craft, tools are now taken, and the templateId is stored in the production in the user profile
When finishing a craft, space for both the tools and crafted item is verified, then both are added to the player stash correctly flagged as non-FiR and FiR respectively

Included a bit of code cleanup/reorg in areas I was working in

A few assumptions were made:
- Tools are expected to be single items, not stacks of an item (productions.json doesn't include a count property for tools, so this seems safe)
- Tools will never be a preset or have child items
- That the `canPlaceItemsInInventory` method over a concatenation of the tools and crafted item(s) will result in the same result as calling it individually over the two collections of items individually

Will need tested once merged into 380, I did basic testing, but there's a lot of different crafts that require tools

Co-authored-by: DrakiaXYZ <565558+TheDgtl@users.noreply.github.com>
Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/234
Co-authored-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com>
Co-committed-by: DrakiaXYZ <drakiaxyz@noreply.dev.sp-tarkov.com>
2024-02-25 08:53:57 +00:00

1156 lines
46 KiB
TypeScript

import { inject, injectable } from "tsyringe";
import { InventoryHelper } from "@spt-aki/helpers/InventoryHelper";
import { ItemHelper } from "@spt-aki/helpers/ItemHelper";
import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper";
import { IPmcData } from "@spt-aki/models/eft/common/IPmcData";
import { HideoutArea, IHideoutImprovement, Production, Productive } from "@spt-aki/models/eft/common/tables/IBotBase";
import { Item, Upd } from "@spt-aki/models/eft/common/tables/IItem";
import { StageBonus } from "@spt-aki/models/eft/hideout/IHideoutArea";
import { IHideoutContinuousProductionStartRequestData } from "@spt-aki/models/eft/hideout/IHideoutContinuousProductionStartRequestData";
import { IHideoutProduction } from "@spt-aki/models/eft/hideout/IHideoutProduction";
import { IHideoutSingleProductionStartRequestData } from "@spt-aki/models/eft/hideout/IHideoutSingleProductionStartRequestData";
import { IHideoutTakeProductionRequestData } from "@spt-aki/models/eft/hideout/IHideoutTakeProductionRequestData";
import { IAddItemsDirectRequest } from "@spt-aki/models/eft/inventory/IAddItemsDirectRequest";
import { IItemEventRouterResponse } from "@spt-aki/models/eft/itemEvent/IItemEventRouterResponse";
import { BonusType } from "@spt-aki/models/enums/BonusType";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { HideoutAreas } from "@spt-aki/models/enums/HideoutAreas";
import { SkillTypes } from "@spt-aki/models/enums/SkillTypes";
import { IHideoutConfig } from "@spt-aki/models/spt/config/IHideoutConfig";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { EventOutputHolder } from "@spt-aki/routers/EventOutputHolder";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { PlayerService } from "@spt-aki/services/PlayerService";
import { HashUtil } from "@spt-aki/utils/HashUtil";
import { HttpResponseUtil } from "@spt-aki/utils/HttpResponseUtil";
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
@injectable()
export class HideoutHelper
{
public static bitcoinFarm = "5d5c205bd582a50d042a3c0e";
public static bitcoinProductionId = "5d5c205bd582a50d042a3c0e";
public static waterCollector = "5d5589c1f934db045e6c5492";
public static bitcoinTpl = "59faff1d86f7746c51718c9c";
public static expeditionaryFuelTank = "5d1b371186f774253763a656";
public static maxSkillPoint = 5000;
protected hideoutConfig: IHideoutConfig;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("EventOutputHolder") protected eventOutputHolder: EventOutputHolder,
@inject("HttpResponseUtil") protected httpResponse: HttpResponseUtil,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("InventoryHelper") protected inventoryHelper: InventoryHelper,
@inject("PlayerService") protected playerService: PlayerService,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("ItemHelper") protected itemHelper: ItemHelper,
@inject("ConfigServer") protected configServer: ConfigServer,
)
{
this.hideoutConfig = this.configServer.getConfig(ConfigTypes.HIDEOUT);
}
/**
* Add production to profiles' Hideout.Production array
* @param pmcData Profile to add production to
* @param body Production request
* @param sessionID Session id
* @returns client response
*/
public registerProduction(
pmcData: IPmcData,
body: IHideoutSingleProductionStartRequestData | IHideoutContinuousProductionStartRequestData,
sessionID: string,
): IItemEventRouterResponse
{
const recipe = this.databaseServer.getTables().hideout.production.find((p) => p._id === body.recipeId);
if (!recipe)
{
this.logger.error(this.localisationService.getText("hideout-missing_recipe_in_db", body.recipeId));
return this.httpResponse.appendErrorToOutput(this.eventOutputHolder.getOutput(sessionID));
}
let modifiedProductionTime = recipe.productionTime
- this.getCraftingSkillProductionTimeReduction(pmcData, recipe.productionTime);
// @Important: Here we need to be very exact:
// - normal recipe: Production time value is stored in attribute "productionType" with small "p"
// - scav case recipe: Production time value is stored in attribute "ProductionType" with capital "P"
if (!pmcData.Hideout.Production)
{
pmcData.Hideout.Production = {};
}
if (modifiedProductionTime > 0 && this.profileHelper.isDeveloperAccount(sessionID))
{
modifiedProductionTime = 40;
}
const production = this.initProduction(
body.recipeId,
modifiedProductionTime,
recipe.needFuelForAllProductionTime,
);
// Store the tools used for this production, so we can return them later
const productionTools = recipe.requirements.filter(req => req.type === "Tool").map(req => req.templateId);
if (productionTools.length > 0)
{
production.sptRequiredTools = productionTools;
}
pmcData.Hideout.Production[body.recipeId] = production;
}
/**
* This convenience function initializes new Production Object
* with all the constants.
*/
public initProduction(recipeId: string, productionTime: number, needFuelForAllProductionTime: boolean): Production
{
return {
Progress: 0,
inProgress: true,
RecipeId: recipeId,
StartTimestamp: this.timeUtil.getTimestamp(),
ProductionTime: productionTime,
Products: [],
GivenItemsInStart: [],
Interrupted: false,
NeedFuelForAllProductionTime: needFuelForAllProductionTime, // Used when sending to client
needFuelForAllProductionTime: needFuelForAllProductionTime, // used when stored in production.json
SkipTime: 0,
};
}
/**
* Is the provided object a Production type
* @param productive
* @returns
*/
public isProductionType(productive: Productive): productive is Production
{
return (productive as Production).Progress !== undefined || (productive as Production).RecipeId !== undefined;
}
/**
* Apply bonus to player profile given after completing hideout upgrades
* @param pmcData Profile to add bonus to
* @param bonus Bonus to add to profile
*/
public applyPlayerUpgradesBonuses(pmcData: IPmcData, bonus: StageBonus): void
{
// Handle additional changes some bonuses need before being added
switch (bonus.type)
{
case BonusType.STASH_SIZE:
{
// Find stash item and adjust tpl to new tpl from bonus
const stashItem = pmcData.Inventory.items.find((x) => x._id === pmcData.Inventory.stash);
if (!stashItem)
{
this.logger.warning(
`Unable to apply StashSize bonus, stash with id: ${pmcData.Inventory.stash} not found`,
);
}
stashItem._tpl = bonus.templateId;
break;
}
case BonusType.MAXIMUM_ENERGY_RESERVE:
// Amend max energy in profile
pmcData.Health.Energy.Maximum += bonus.value;
break;
case BonusType.TEXT_BONUS:
// Delete values before they're added to profile
delete bonus.passive;
delete bonus.production;
delete bonus.visible;
break;
}
// Add bonus to player bonuses array in profile
// EnergyRegeneration, HealthRegeneration, RagfairCommission, ScavCooldownTimer, SkillGroupLevelingBoost, ExperienceRate, QuestMoneyReward etc
this.logger.debug(`Adding bonus: ${bonus.type} to profile, value: ${bonus.value ?? ""}`);
pmcData.Bonuses.push(bonus);
}
/**
* Process a players hideout, update areas that use resources + increment production timers
* @param sessionID Session id
*/
public updatePlayerHideout(sessionID: string): void
{
const pmcData = this.profileHelper.getPmcProfile(sessionID);
const hideoutProperties = this.getHideoutProperties(pmcData);
this.updateAreasWithResources(sessionID, pmcData, hideoutProperties);
this.updateProductionTimers(pmcData, hideoutProperties);
pmcData.Hideout.sptUpdateLastRunTimestamp = this.timeUtil.getTimestamp();
}
/**
* Get various properties that will be passed to hideout update-related functions
* @param pmcData Player profile
* @returns Properties
*/
protected getHideoutProperties(
pmcData: IPmcData,
): { btcFarmCGs: number; isGeneratorOn: boolean; waterCollectorHasFilter: boolean; }
{
const bitcoinFarm = pmcData.Hideout.Areas.find((x) => x.type === HideoutAreas.BITCOIN_FARM);
const bitcoinCount = bitcoinFarm?.slots.filter((slot) => slot.item).length ?? 0; // Get slots with an item property
const hideoutProperties = {
btcFarmCGs: bitcoinCount,
isGeneratorOn: pmcData.Hideout.Areas.find((x) => x.type === HideoutAreas.GENERATOR)?.active ?? false,
waterCollectorHasFilter: this.doesWaterCollectorHaveFilter(
pmcData.Hideout.Areas.find((x) => x.type === HideoutAreas.WATER_COLLECTOR),
),
};
return hideoutProperties;
}
protected doesWaterCollectorHaveFilter(waterCollector: HideoutArea): boolean
{
if (waterCollector.level === 3)
{ // can put filters in from L3
// Has filter in at least one slot
return waterCollector.slots.some((x) => x.item);
}
// No Filter
return false;
}
/**
* Update progress timer for water collector
* @param pmcData profile to update
* @param productionId id of water collection production to update
* @param hideoutProperties Hideout properties
*/
protected updateWaterCollectorProductionTimer(
pmcData: IPmcData,
productionId: string,
hideoutProperties: { btcFarmCGs?: number; isGeneratorOn: boolean; waterCollectorHasFilter: boolean; },
): void
{
const timeElapsed = this.getTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.isGeneratorOn);
if (hideoutProperties.waterCollectorHasFilter)
{
pmcData.Hideout.Production[productionId].Progress += timeElapsed;
}
}
/**
* Iterate over productions and update their progress timers
* @param pmcData Profile to check for productions and update
* @param hideoutProperties Hideout properties
*/
protected updateProductionTimers(
pmcData: IPmcData,
hideoutProperties: { btcFarmCGs: number; isGeneratorOn: boolean; waterCollectorHasFilter: boolean; },
): void
{
const recipes = this.databaseServer.getTables().hideout.production;
// Check each production
for (const prodId in pmcData.Hideout.Production)
{
const craft = pmcData.Hideout.Production[prodId];
if (!craft)
{
// Craft value is null, get rid of it (could be from cancelling craft that needs cleaning up)
delete pmcData.Hideout.Production[prodId];
continue;
}
if (craft.Progress === undefined || craft.Progress === null)
{
this.logger.warning(`Craft ${prodId} has an undefined progress value, defaulting to 0`);
craft.Progress = 0;
}
// Craft complete, skip processing (Don't skip continious crafts like bitcoin farm)
if (craft.Progress >= craft.ProductionTime && prodId !== HideoutHelper.bitcoinFarm)
{
continue;
}
if (craft.sptIsScavCase)
{
this.updateScavCaseProductionTimer(pmcData, prodId);
continue;
}
if (prodId === HideoutHelper.waterCollector)
{
this.updateWaterCollectorProductionTimer(pmcData, prodId, hideoutProperties);
continue;
}
if (prodId === HideoutHelper.bitcoinFarm)
{
pmcData.Hideout.Production[prodId] = this.updateBitcoinFarm(
pmcData,
hideoutProperties.btcFarmCGs,
hideoutProperties.isGeneratorOn,
);
continue;
}
// Other recipes not covered by above
const recipe = recipes.find((r) => r._id === prodId);
if (!recipe)
{
this.logger.error(this.localisationService.getText("hideout-missing_recipe_for_area", prodId));
continue;
}
this.updateProductionProgress(pmcData, prodId, recipe, hideoutProperties);
}
}
/**
* Update a productions progress value based on the amount of time that has passed
* @param pmcData Player profile
* @param prodId Production id being crafted
* @param recipe Recipe data being crafted
* @param hideoutProperties
*/
protected updateProductionProgress(
pmcData: IPmcData,
prodId: string,
recipe: IHideoutProduction,
hideoutProperties: { btcFarmCGs?: number; isGeneratorOn: boolean; waterCollectorHasFilter?: boolean; },
): void
{
// Production is complete, no need to do any calculations
if (this.doesProgressMatchProductionTime(pmcData, prodId))
{
return;
}
// Get seconds since last hideout update + now
const timeElapsed = this.getTimeElapsedSinceLastServerTick(pmcData, hideoutProperties.isGeneratorOn, recipe);
// Increment progress by time passed
const production = pmcData.Hideout.Production[prodId];
production.Progress += (production.needFuelForAllProductionTime && !hideoutProperties.isGeneratorOn)
? 0
: timeElapsed; // Some items NEED power to craft (e.g. DSP)
// Limit progress to total production time if progress is over (dont run for continious crafts))
if (!recipe.continuous)
{
// If progress is larger than prod time, return ProductionTime, hard cap the vaue
production.Progress = Math.min(production.Progress, production.ProductionTime);
}
}
/**
* Check if a productions progress value matches its corresponding recipes production time value
* @param pmcData Player profile
* @param prodId Production id
* @param recipe Recipe being crafted
* @returns progress matches productionTime from recipe
*/
protected doesProgressMatchProductionTime(pmcData: IPmcData, prodId: string): boolean
{
return pmcData.Hideout.Production[prodId].Progress === pmcData.Hideout.Production[prodId].ProductionTime;
}
/**
* Update progress timer for scav case
* @param pmcData Profile to update
* @param productionId Id of scav case production to update
*/
protected updateScavCaseProductionTimer(pmcData: IPmcData, productionId: string): void
{
const timeElapsed = (this.timeUtil.getTimestamp() - pmcData.Hideout.Production[productionId].StartTimestamp)
- pmcData.Hideout.Production[productionId].Progress;
pmcData.Hideout.Production[productionId].Progress += timeElapsed;
}
/**
* Iterate over hideout areas that use resources (fuel/filters etc) and update associated values
* @param sessionID Session id
* @param pmcData Profile to update areas of
* @param hideoutProperties hideout properties
*/
protected updateAreasWithResources(
sessionID: string,
pmcData: IPmcData,
hideoutProperties: { btcFarmCGs: number; isGeneratorOn: boolean; waterCollectorHasFilter: boolean; },
): void
{
for (const area of pmcData.Hideout.Areas)
{
switch (area.type)
{
case HideoutAreas.GENERATOR:
if (hideoutProperties.isGeneratorOn)
{
this.updateFuel(area, pmcData, hideoutProperties.isGeneratorOn);
}
break;
case HideoutAreas.WATER_COLLECTOR:
this.updateWaterCollector(sessionID, pmcData, area, hideoutProperties.isGeneratorOn);
break;
case HideoutAreas.AIR_FILTERING:
if (hideoutProperties.isGeneratorOn)
{
this.updateAirFilters(area, pmcData, hideoutProperties.isGeneratorOn);
}
break;
}
}
}
/**
* Decrease fuel from generator slots based on amount of time since last time this occured
* @param generatorArea Hideout area
* @param pmcData Player profile
* @param isGeneratorOn Is the generator turned on since last update
*/
protected updateFuel(generatorArea: HideoutArea, pmcData: IPmcData, isGeneratorOn: boolean): void
{
// 1 resource last 14 min 27 sec, 1/14.45/60 = 0.00115
// 10-10-2021 From wiki, 1 resource last 12 minutes 38 seconds, 1/12.63333/60 = 0.00131
let fuelUsedSinceLastTick = this.databaseServer.getTables().hideout.settings.generatorFuelFlowRate
* this.getTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
const fuelConsumptionBonus = pmcData.Bonuses.find((bonus) => bonus.type === BonusType.FUEL_CONSUMPTION);
const fuelConsumptionBonusPercent = 1.0
- (fuelConsumptionBonus ? Math.abs(fuelConsumptionBonus.value) : 0) / 100;
fuelUsedSinceLastTick *= fuelConsumptionBonusPercent;
// Hideout management resource consumption bonus:
const hideoutManagementConsumptionBonus = 1.0 - this.getHideoutManagementConsumptionBonus(pmcData);
fuelUsedSinceLastTick *= hideoutManagementConsumptionBonus;
let hasFuelRemaining = false;
let pointsConsumed = 0;
for (let i = 0; i < generatorArea.slots.length; i++)
{
const generatorSlot = generatorArea.slots[i];
if (!generatorSlot?.item)
{
// No item in slot, skip
continue;
}
const fuelItemInSlot = generatorSlot?.item[0];
if (!fuelItemInSlot)
{
// No item in slot, skip
continue;
}
let fuelRemaining = fuelItemInSlot.upd?.Resource?.Value;
if (fuelRemaining === 0)
{
// No fuel left, skip
continue;
}
// Undefined fuel, fresh fuel item and needs its max fuel amount looked up
if (!fuelRemaining)
{
const fuelItemTemplate = this.itemHelper.getItem(fuelItemInSlot._tpl)[1];
pointsConsumed = fuelUsedSinceLastTick;
fuelRemaining = fuelItemTemplate._props.MaxResource - fuelUsedSinceLastTick;
}
else
{
// Fuel exists already, deduct fuel from item remaining value
pointsConsumed = (fuelItemInSlot.upd.Resource.UnitsConsumed || 0) + fuelUsedSinceLastTick;
fuelRemaining -= fuelUsedSinceLastTick;
}
fuelRemaining = Math.round(fuelRemaining * 10000) / 10000;
pointsConsumed = Math.round(pointsConsumed * 10000) / 10000;
// Fuel consumed / 10 is over 1, add hideout management skill point
if (pmcData && Math.floor(pointsConsumed / 10) >= 1)
{
this.profileHelper.addSkillPointsToPlayer(pmcData, SkillTypes.HIDEOUT_MANAGEMENT, 1);
pointsConsumed -= 10;
}
if (fuelRemaining > 0)
{
// Deducted all used fuel from this container, clean up and exit loop
fuelItemInSlot.upd = this.getAreaUpdObject(1, fuelRemaining, pointsConsumed);
this.logger.debug(
`Profile: ${pmcData._id} Generator has: ${fuelRemaining} fuel left in slot ${i + 1}`,
true,
);
hasFuelRemaining = true;
break; // Break to avoid updating all the fuel tanks
}
fuelItemInSlot.upd = this.getAreaUpdObject(1, 0, 0);
// Ran out of fuel items to deduct fuel from
fuelUsedSinceLastTick = Math.abs(fuelRemaining);
this.logger.debug(`Profile: ${pmcData._id} Generator ran out of fuel`, true);
}
// Out of fuel, flag generator as offline
if (!hasFuelRemaining)
{
generatorArea.active = false;
}
}
protected updateWaterCollector(
sessionId: string,
pmcData: IPmcData,
area: HideoutArea,
isGeneratorOn: boolean,
): void
{
// Skip water collector when not level 3 (cant collect until 3)
if (area.level !== 3)
{
return;
}
// Canister with purified water craft exists
const prod = pmcData.Hideout.Production[HideoutHelper.waterCollector];
if (prod && this.isProduction(prod))
{
this.updateWaterFilters(area, prod, isGeneratorOn, pmcData);
}
else
{
// continuousProductionStart()
// seem to not trigger consistently
const recipe: IHideoutSingleProductionStartRequestData = {
recipeId: HideoutHelper.waterCollector,
Action: "HideoutSingleProductionStart",
items: [],
tools: [],
timestamp: this.timeUtil.getTimestamp(),
};
this.registerProduction(pmcData, recipe, sessionId);
}
}
/**
* Adjust water filter objects resourceValue or delete when they reach 0 resource
* @param waterFilterArea water filter area to update
* @param production production object
* @param isGeneratorOn is generator enabled
* @param pmcData Player profile
*/
protected updateWaterFilters(
waterFilterArea: HideoutArea,
production: Production,
isGeneratorOn: boolean,
pmcData: IPmcData,
): void
{
let filterDrainRate = this.getWaterFilterDrainRate(pmcData);
const productionTime = this.getTotalProductionTimeSeconds(HideoutHelper.waterCollector);
const secondsSinceServerTick = this.getTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
filterDrainRate = this.adjustWaterFilterDrainRate(
secondsSinceServerTick,
productionTime,
production.Progress,
filterDrainRate,
);
// Production hasn't completed
let pointsConsumed = 0;
if (production.Progress < productionTime)
{
// Check all slots that take water filters
// Must loop to find first water filter and use that
for (let i = 0; i < waterFilterArea.slots.length; i++)
{
// Has a water filter installed into slot
if (waterFilterArea.slots[i].item)
{
// How many units of filter are left
let resourceValue = (waterFilterArea.slots[i].item[0].upd?.Resource)
? waterFilterArea.slots[i].item[0].upd.Resource.Value
: null;
if (!resourceValue)
{
// None left
resourceValue = 100 - filterDrainRate;
pointsConsumed = filterDrainRate;
}
else
{
pointsConsumed = (waterFilterArea.slots[i].item[0].upd.Resource.UnitsConsumed || 0)
+ filterDrainRate;
resourceValue -= filterDrainRate;
}
// Round to get values to 3dp
resourceValue = Math.round(resourceValue * 10000) / 10000;
pointsConsumed = Math.round(pointsConsumed * 10000) / 10000;
// Check amount of units consumed for possible increment of hideout mgmt skill point
if (pmcData && Math.floor(pointsConsumed / 10) >= 1)
{
this.profileHelper.addSkillPointsToPlayer(pmcData, SkillTypes.HIDEOUT_MANAGEMENT, 1);
pointsConsumed -= 10;
}
// Filter has some juice left in it after we adjusted it
if (resourceValue > 0)
{
// Set filter consumed amount
waterFilterArea.slots[i].item[0].upd = this.getAreaUpdObject(1, resourceValue, pointsConsumed);
this.logger.debug(`Water filter has: ${resourceValue} units left in slot ${i + 1}`);
break; // Break here to avoid updating all filters
}
// Filter ran out / used up
delete waterFilterArea.slots[i].item;
// Update remaining resources to be subtracted
filterDrainRate = Math.abs(resourceValue);
}
}
}
}
/**
* Get an adjusted water filter drain rate based on time elapsed since last run,
* handle edge case when craft time has gone on longer than total production time
* @param secondsSinceServerTick Time passed
* @param totalProductionTime Total time collecting water
* @param productionProgress how far water collector has progressed
* @param baseFilterDrainRate Base drain rate
* @returns
*/
protected adjustWaterFilterDrainRate(
secondsSinceServerTick: number,
totalProductionTime: number,
productionProgress: number,
baseFilterDrainRate: number,
): number
{
const drainRateMultiplier = secondsSinceServerTick > totalProductionTime
? (totalProductionTime - productionProgress) // more time passed than prod time, get total minus the current progress
: secondsSinceServerTick;
// Multiply drain rate by calculated multiplier
baseFilterDrainRate *= drainRateMultiplier;
return baseFilterDrainRate;
}
/**
* Get the water filter drain rate based on hideout bonues player has
* @param pmcData Player profile
* @returns Drain rate
*/
protected getWaterFilterDrainRate(pmcData: IPmcData): number
{
// 100 resources last 8 hrs 20 min, 100/8.33/60/60 = 0.00333
const filterDrainRate = 0.00333;
const hideoutManagementConsumptionBonus = 1.0 - this.getHideoutManagementConsumptionBonus(pmcData);
return filterDrainRate * hideoutManagementConsumptionBonus;
}
/**
* Get the production time in seconds for the desired production
* @param prodId Id, e.g. Water collector id
* @returns seconds to produce item
*/
protected getTotalProductionTimeSeconds(prodId: string): number
{
const recipe = this.databaseServer.getTables().hideout.production.find((prod) => prod._id === prodId);
return (recipe.productionTime || 0);
}
/**
* Create a upd object using passed in parameters
* @param stackCount
* @param resourceValue
* @param resourceUnitsConsumed
* @returns Upd
*/
protected getAreaUpdObject(stackCount: number, resourceValue: number, resourceUnitsConsumed: number): Upd
{
return {
StackObjectsCount: stackCount,
Resource: { Value: resourceValue, UnitsConsumed: resourceUnitsConsumed },
};
}
protected updateAirFilters(airFilterArea: HideoutArea, pmcData: IPmcData, isGeneratorOn: boolean): void
{
// 300 resources last 20 hrs, 300/20/60/60 = 0.00416
/* 10-10-2021 from WIKI (https://escapefromtarkov.fandom.com/wiki/FP-100_filter_absorber)
Lasts for 17 hours 38 minutes and 49 seconds (23 hours 31 minutes and 45 seconds with elite hideout management skill),
300/17.64694/60/60 = 0.004722
*/
let filterDrainRate = this.databaseServer.getTables().hideout.settings.airFilterUnitFlowRate
* this.getTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
// Hideout management resource consumption bonus:
const hideoutManagementConsumptionBonus = 1.0 - this.getHideoutManagementConsumptionBonus(pmcData);
filterDrainRate *= hideoutManagementConsumptionBonus;
let pointsConsumed = 0;
for (let i = 0; i < airFilterArea.slots.length; i++)
{
if (airFilterArea.slots[i].item)
{
let resourceValue = (airFilterArea.slots[i].item[0].upd?.Resource)
? airFilterArea.slots[i].item[0].upd.Resource.Value
: null;
if (!resourceValue)
{
resourceValue = 300 - filterDrainRate;
pointsConsumed = filterDrainRate;
}
else
{
pointsConsumed = (airFilterArea.slots[i].item[0].upd.Resource.UnitsConsumed || 0) + filterDrainRate;
resourceValue -= filterDrainRate;
}
resourceValue = Math.round(resourceValue * 10000) / 10000;
pointsConsumed = Math.round(pointsConsumed * 10000) / 10000;
// check unit consumed for increment skill point
if (pmcData && Math.floor(pointsConsumed / 10) >= 1)
{
this.profileHelper.addSkillPointsToPlayer(pmcData, SkillTypes.HIDEOUT_MANAGEMENT, 1);
pointsConsumed -= 10;
}
if (resourceValue > 0)
{
airFilterArea.slots[i].item[0].upd = {
StackObjectsCount: 1,
Resource: { Value: resourceValue, UnitsConsumed: pointsConsumed },
};
this.logger.debug(`Air filter: ${resourceValue} filter left on slot ${i + 1}`);
break; // Break here to avoid updating all filters
}
delete airFilterArea.slots[i].item;
// Update remaining resources to be subtracted
filterDrainRate = Math.abs(resourceValue);
}
}
}
protected updateBitcoinFarm(pmcData: IPmcData, btcFarmCGs: number, isGeneratorOn: boolean): Production
{
const btcProd = pmcData.Hideout.Production[HideoutHelper.bitcoinFarm];
const bitcoinProdData = this.databaseServer.getTables().hideout.production.find((production) =>
production._id === HideoutHelper.bitcoinProductionId
);
const coinSlotCount = this.getBTCSlots(pmcData);
// Full on bitcoins, halt progress
if (this.isProduction(btcProd) && btcProd.Products.length >= coinSlotCount)
{
// Set progress to 0
btcProd.Progress = 0;
return btcProd;
}
if (this.isProduction(btcProd))
{
// The wiki has a wrong formula!
// Do not change unless you validate it with the Client code files!
// This formula was found on the client files:
// *******************************************************
/*
public override int InstalledSuppliesCount
{
get
{
return this.int_1;
}
protected set
{
if (this.int_1 === value)
{
return;
}
this.int_1 = value;
base.Single_0 = ((this.int_1 === 0) ? 0f : (1f + (float)(this.int_1 - 1) * this.float_4));
}
}
*/
// **********************************************************
// At the time of writing this comment, this was GClass1667
// To find it in case of weird results, use DNSpy and look for usages on class AreaData
// Look for a GClassXXXX that has a method called "InitDetails" and the only parameter is the AreaData
// That should be the bitcoin farm production. To validate, try to find the snippet below:
/*
protected override void InitDetails(AreaData data)
{
base.InitDetails(data);
this.gclass1678_1.Type = EDetailsType.Farming;
}
*/
// BSG finally fixed their settings, they now get loaded from the settings and used in the client
const adjustedCraftTime =
((this.profileHelper.isDeveloperAccount(pmcData.sessionId)) ? 40 : bitcoinProdData.productionTime)
/ (1 + (btcFarmCGs - 1) * this.databaseServer.getTables().hideout.settings.gpuBoostRate);
// The progress should be adjusted based on the GPU boost rate, but the target is still the base productionTime
const timeMultiplier = bitcoinProdData.productionTime / adjustedCraftTime;
const timeElapsedSeconds = this.getTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
btcProd.Progress += Math.floor(timeElapsedSeconds * timeMultiplier);
while (btcProd.Progress >= bitcoinProdData.productionTime)
{
if (btcProd.Products.length < coinSlotCount)
{
// Has space to add a coin to production
this.addBtcToProduction(btcProd, bitcoinProdData.productionTime);
}
else
{
// Filled up bitcoin storage
btcProd.Progress = 0;
}
}
btcProd.StartTimestamp = this.timeUtil.getTimestamp();
return btcProd;
}
return null;
}
/**
* Add bitcoin object to btc production products array and set progress time
* @param btcProd Bitcoin production object
* @param coinCraftTimeSeconds Time to craft a bitcoin
*/
protected addBtcToProduction(btcProd: Production, coinCraftTimeSeconds: number): void
{
btcProd.Products.push({
_id: this.hashUtil.generate(),
_tpl: HideoutHelper.bitcoinTpl,
upd: { StackObjectsCount: 1 },
});
// Deduct time spent crafting from progress
btcProd.Progress -= coinCraftTimeSeconds;
}
/**
* Get number of ticks that have passed since hideout areas were last processed, reduced when generator is off
* @param pmcData Player profile
* @param isGeneratorOn Is the generator on for the duration of elapsed time
* @param recipe Hideout production recipe being crafted we need the ticks for
* @returns Amount of time elapsed in seconds
*/
protected getTimeElapsedSinceLastServerTick(
pmcData: IPmcData,
isGeneratorOn: boolean,
recipe: IHideoutProduction = null,
): number
{
// Reduce time elapsed (and progress) when generator is off
let timeElapsed = this.timeUtil.getTimestamp() - pmcData.Hideout.sptUpdateLastRunTimestamp;
if (recipe?.areaType === HideoutAreas.LAVATORY)
{
// Lavatory works at 100% when power is on / off
return timeElapsed;
}
if (!isGeneratorOn)
{
timeElapsed *= this.databaseServer.getTables().hideout.settings.generatorSpeedWithoutFuel;
}
return timeElapsed;
}
/**
* Get a count of how many possible BTC can be gathered by the profile
* @param pmcData Profile to look up
* @returns Coin slot count
*/
protected getBTCSlots(pmcData: IPmcData): number
{
const bitcoinProductions = this.databaseServer.getTables().hideout.production.find((production) =>
production._id === HideoutHelper.bitcoinFarm
);
const productionSlots = bitcoinProductions?.productionLimitCount || 3; // Default to 3 if none found
const hasManagementSkillSlots = this.profileHelper.hasEliteSkillLevel(SkillTypes.HIDEOUT_MANAGEMENT, pmcData);
const managementSlotsCount = this.getEliteSkillAdditionalBitcoinSlotCount() || 2;
return productionSlots + (hasManagementSkillSlots ? managementSlotsCount : 0);
}
/**
* Get a count of how many additional bitcoins player hideout can hold with elite skill
*/
protected getEliteSkillAdditionalBitcoinSlotCount(): number
{
return this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement.EliteSlots.BitcoinFarm
.Container;
}
/**
* HideoutManagement skill gives a consumption bonus the higher the level
* 0.5% per level per 1-51, (25.5% at max)
* @param pmcData Profile to get hideout consumption level level from
* @returns consumption bonus
*/
protected getHideoutManagementConsumptionBonus(pmcData: IPmcData): number
{
const hideoutManagementSkill = this.profileHelper.getSkillFromProfile(pmcData, SkillTypes.HIDEOUT_MANAGEMENT);
if (!hideoutManagementSkill)
{
return 0;
}
// If the level is 51 we need to round it at 50 so on elite you dont get 25.5%
// at level 1 you already get 0.5%, so it goes up until level 50. For some reason the wiki
// says that it caps at level 51 with 25% but as per dump data that is incorrect apparently
let roundedLevel = Math.floor(hideoutManagementSkill.Progress / 100);
roundedLevel = (roundedLevel === 51) ? roundedLevel - 1 : roundedLevel;
return (roundedLevel
* this.databaseServer.getTables().globals.config.SkillsSettings.HideoutManagement
.ConsumptionReductionPerLevel) / 100;
}
/**
* Adjust craft time based on crafting skill level found in player profile
* @param pmcData Player profile
* @param productionTime Time to complete hideout craft in seconds
* @returns Adjusted craft time in seconds
*/
protected getCraftingSkillProductionTimeReduction(pmcData: IPmcData, productionTime: number): number
{
const craftingSkill = pmcData.Skills.Common.find((x) => x.Id === SkillTypes.CRAFTING);
if (!craftingSkill)
{
return productionTime;
}
const roundedLevel = Math.floor(Math.min(HideoutHelper.maxSkillPoint, craftingSkill.Progress) / 100);
const percentageToDrop = roundedLevel * 0.75;
return (productionTime * percentageToDrop) / 100;
}
public isProduction(productive: Productive): productive is Production
{
return (productive as Production).Progress !== undefined || (productive as Production).RecipeId !== undefined;
}
/**
* Gather crafted BTC from hideout area and add to inventory
* Reset production start timestamp if hideout area at full coin capacity
* @param pmcData Player profile
* @param request Take production request
* @param sessionId Session id
* @param output Output object to update
*/
public getBTC(
pmcData: IPmcData,
request: IHideoutTakeProductionRequestData,
sessionId: string,
output: IItemEventRouterResponse,
): void
{
// Get how many coins were crafted and ready to pick up
const craftedCoinCount = pmcData.Hideout.Production[HideoutHelper.bitcoinFarm].Products.length;
if (!craftedCoinCount)
{
const errorMsg = this.localisationService.getText("hideout-no_bitcoins_to_collect");
this.logger.error(errorMsg);
this.httpResponse.appendErrorToOutput(output, errorMsg);
return;
}
const itemsToAdd: Item[][] = [];
for (let index = 0; index < craftedCoinCount; index++)
{
itemsToAdd.push([{
_id: this.hashUtil.generate(),
_tpl: HideoutHelper.bitcoinTpl,
upd: { StackObjectsCount: 1 },
}]);
}
// Create request for what we want to add to stash
const addItemsRequest: IAddItemsDirectRequest = {
itemsWithModsToAdd: itemsToAdd,
foundInRaid: true,
useSortingTable: false,
callback: null,
};
// Add FiR coins to player inventory
this.inventoryHelper.addItemsToStash(sessionId, addItemsRequest, pmcData, output);
if (output.warnings.length > 0)
{
return;
}
// Is at max capacity + we collected all coins - reset production start time
const coinSlotCount = this.getBTCSlots(pmcData);
if (pmcData.Hideout.Production[HideoutHelper.bitcoinFarm].Products.length >= coinSlotCount)
{
// Set start to now
pmcData.Hideout.Production[HideoutHelper.bitcoinFarm].StartTimestamp = this.timeUtil.getTimestamp();
}
// Remove crafted coins from production in profile now they've been collected
// Can only collect all coins, not individially
pmcData.Hideout.Production[HideoutHelper.bitcoinFarm].Products = [];
}
/**
* Upgrade hideout wall from starting level to interactable level if necessary stations have been upgraded
* @param pmcProfile Profile to upgrade wall in
*/
public unlockHideoutWallInProfile(pmcProfile: IPmcData): void
{
const waterCollector = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.WATER_COLLECTOR);
const medStation = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.MEDSTATION);
const wall = pmcProfile.Hideout.Areas.find((x) => x.type === HideoutAreas.EMERGENCY_WALL);
// No collector or med station, skip
if (!(waterCollector && medStation))
{
return;
}
// If medstation > level 1 AND water collector > level 1 AND wall is level 0
if (waterCollector?.level >= 1 && medStation?.level >= 1 && wall?.level <= 0)
{
wall.level = 3;
}
}
/**
* Hideout improvement is flagged as complete
* @param improvement hideout improvement object
* @returns true if complete
*/
protected hideoutImprovementIsComplete(improvement: IHideoutImprovement): boolean
{
return improvement?.completed ? true : false;
}
/**
* Iterate over hideout improvements not completed and check if they need to be adjusted
* @param pmcProfile Profile to adjust
*/
public setHideoutImprovementsToCompleted(pmcProfile: IPmcData): void
{
for (const improvementId in pmcProfile.Hideout.Improvement)
{
const improvementDetails = pmcProfile.Hideout.Improvement[improvementId];
if (
improvementDetails.completed === false
&& improvementDetails.improveCompleteTimestamp < this.timeUtil.getTimestamp()
)
{
improvementDetails.completed = true;
}
}
}
/**
* Add/remove bonus combat skill based on number of dogtags in place of fame hideout area
* @param pmcData Player profile
*/
public applyPlaceOfFameDogtagBonus(pmcData: IPmcData): void
{
const fameAreaProfile = pmcData.Hideout.Areas.find((area) => area.type === HideoutAreas.PLACE_OF_FAME);
// Get hideout area 16 bonus array
const fameAreaDb = this.databaseServer.getTables().hideout.areas.find((area) =>
area.type === HideoutAreas.PLACE_OF_FAME
);
// Get SkillGroupLevelingBoost object
const combatBoostBonusDb = fameAreaDb.stages[fameAreaProfile.level].bonuses.find((bonus) =>
bonus.type === "SkillGroupLevelingBoost"
);
// Get SkillGroupLevelingBoost object in profile
const combatBonusProfile = pmcData.Bonuses.find((bonus) => bonus.id === combatBoostBonusDb.id);
// Get all slotted dogtag items
const activeDogtags = pmcData.Inventory.items.filter((item) => item?.slotId?.startsWith("dogtag"));
// Calculate bonus percent (apply hideoutManagement bonus)
const hideoutManagementSkill = this.profileHelper.getSkillFromProfile(pmcData, SkillTypes.HIDEOUT_MANAGEMENT);
const hideoutManagementSkillBonusPercent = 1 + (hideoutManagementSkill.Progress / 10000); // 5100 becomes 0.51, add 1 to it, 1.51
const bonus = this.getDogtagCombatSkillBonusPercent(pmcData, activeDogtags)
* hideoutManagementSkillBonusPercent;
// Update bonus value to above calcualted value
combatBonusProfile.value = Number.parseFloat(bonus.toFixed(2));
}
/**
* Calculate the raw dogtag combat skill bonus for place of fame based on number of dogtags
* Reverse engineered from client code
* @param pmcData Player profile
* @param activeDogtags Active dogtags in place of fame dogtag slots
* @returns combat bonus
*/
protected getDogtagCombatSkillBonusPercent(pmcData: IPmcData, activeDogtags: Item[]): number
{
// Not own dogtag
// Side = opposite of player
let result = 0;
for (const dogtag of activeDogtags)
{
if (!dogtag.upd.Dogtag)
{
continue;
}
if (Number.parseInt(dogtag.upd.Dogtag?.AccountId) === pmcData.aid)
{
continue;
}
result += 0.01 * dogtag.upd.Dogtag.Level;
}
return result;
}
}