Server/project/src/helpers/HideoutHelper.ts
Dev b34edb0d3b Adjustments to profile saving logging logic
Bundle up profile save time and display in one line + do not log to file
Don't log when each profile is saved
Do not log to file how much fuel is left in generator
2024-02-10 10:07:40 +00:00

1140 lines
45 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;
}
pmcData.Hideout.Production[body.recipeId] = this.initProduction(
body.recipeId,
modifiedProductionTime,
recipe.needFuelForAllProductionTime,
);
}
/**
* 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 fuelDrainRate = this.databaseServer.getTables().hideout.settings.generatorFuelFlowRate
* this.getTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
const fuelBonus = pmcData.Bonuses.find((bonus) => bonus.type === BonusType.FUEL_CONSUMPTION);
const fuelBonusPercent = 1.0 - (fuelBonus ? Math.abs(fuelBonus.value) : 0) / 100;
fuelDrainRate *= fuelBonusPercent;
// Hideout management resource consumption bonus:
const hideoutManagementConsumptionBonus = 1.0 - this.getHideoutManagementConsumptionBonus(pmcData);
fuelDrainRate *= hideoutManagementConsumptionBonus;
let hasFuelRemaining = false;
let pointsConsumed = 0;
for (let i = 0; i < generatorArea.slots.length; i++)
{
if (!generatorArea.slots[i]?.item)
{
// No item in slot, skip
continue;
}
const fuelItemInSlot = generatorArea.slots[i]?.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 = fuelDrainRate;
fuelRemaining = fuelItemTemplate._props.MaxResource - fuelDrainRate;
}
else
{
// Fuel exists already, deduct fuel from item remaining value
pointsConsumed = (fuelItemInSlot.upd.Resource.UnitsConsumed || 0) + fuelDrainRate;
fuelRemaining -= fuelDrainRate;
}
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)
{
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 here to avoid updating all the fuel tanks
}
fuelItemInSlot.upd = this.getAreaUpdObject(1, 0, 0);
// Update remaining resources to be subtracted
fuelDrainRate = Math.abs(fuelRemaining);
}
// 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: [],
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))
{
const timeElapsedSeconds = this.getTimeElapsedSinceLastServerTick(pmcData, isGeneratorOn);
btcProd.Progress += timeElapsedSeconds;
// 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 coinCraftTimeSeconds =
((this.profileHelper.isDeveloperAccount(pmcData.sessionId)) ? 40 : bitcoinProdData.productionTime)
/ (1 + (btcFarmCGs - 1) * this.databaseServer.getTables().hideout.settings.gpuBoostRate);
while (btcProd.Progress >= coinCraftTimeSeconds)
{
if (btcProd.Products.length < coinSlotCount)
{
// Has space to add a coin to production
this.addBtcToProduction(btcProd, coinCraftTimeSeconds);
}
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;
}
}