Server/project/src/services/LocationLifecycleService.ts

993 lines
42 KiB
TypeScript

import { ApplicationContext } from "@spt/context/ApplicationContext";
import { ContextVariableType } from "@spt/context/ContextVariableType";
import { LocationLootGenerator } from "@spt/generators/LocationLootGenerator";
import { LootGenerator } from "@spt/generators/LootGenerator";
import { PlayerScavGenerator } from "@spt/generators/PlayerScavGenerator";
import { HealthHelper } from "@spt/helpers/HealthHelper";
import { InRaidHelper } from "@spt/helpers/InRaidHelper";
import { ProfileHelper } from "@spt/helpers/ProfileHelper";
import { QuestHelper } from "@spt/helpers/QuestHelper";
import { TraderHelper } from "@spt/helpers/TraderHelper";
import { ILocationBase } from "@spt/models/eft/common/ILocationBase";
import { IPmcData } from "@spt/models/eft/common/IPmcData";
import { Common, IQuestStatus, ITraderInfo } from "@spt/models/eft/common/tables/IBotBase";
import { IItem } from "@spt/models/eft/common/tables/IItem";
import {
IEndLocalRaidRequestData,
IEndRaidResult,
ILocationTransit,
} from "@spt/models/eft/match/IEndLocalRaidRequestData";
import { IStartLocalRaidRequestData } from "@spt/models/eft/match/IStartLocalRaidRequestData";
import { IStartLocalRaidResponseData } from "@spt/models/eft/match/IStartLocalRaidResponseData";
import { ConfigTypes } from "@spt/models/enums/ConfigTypes";
import { ExitStatus } from "@spt/models/enums/ExitStatis";
import { MessageType } from "@spt/models/enums/MessageType";
import { QuestStatus } from "@spt/models/enums/QuestStatus";
import { Traders } from "@spt/models/enums/Traders";
import { IHideoutConfig } from "@spt/models/spt/config/IHideoutConfig";
import { IInRaidConfig } from "@spt/models/spt/config/IInRaidConfig";
import { ILocationConfig } from "@spt/models/spt/config/ILocationConfig";
import { IPmcConfig } from "@spt/models/spt/config/IPmcConfig";
import { IRagfairConfig } from "@spt/models/spt/config/IRagfairConfig";
import { ITraderConfig } from "@spt/models/spt/config/ITraderConfig";
import { IRaidChanges } from "@spt/models/spt/location/IRaidChanges";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { ConfigServer } from "@spt/servers/ConfigServer";
import { SaveServer } from "@spt/servers/SaveServer";
import { BotGenerationCacheService } from "@spt/services/BotGenerationCacheService";
import { BotLootCacheService } from "@spt/services/BotLootCacheService";
import { BotNameService } from "@spt/services/BotNameService";
import { DatabaseService } from "@spt/services/DatabaseService";
import { InsuranceService } from "@spt/services/InsuranceService";
import { LocalisationService } from "@spt/services/LocalisationService";
import { MailSendService } from "@spt/services/MailSendService";
import { MatchBotDetailsCacheService } from "@spt/services/MatchBotDetailsCacheService";
import { PmcChatResponseService } from "@spt/services/PmcChatResponseService";
import { RaidTimeAdjustmentService } from "@spt/services/RaidTimeAdjustmentService";
import { HashUtil } from "@spt/utils/HashUtil";
import { RandomUtil } from "@spt/utils/RandomUtil";
import { TimeUtil } from "@spt/utils/TimeUtil";
import { ICloner } from "@spt/utils/cloners/ICloner";
import { inject, injectable } from "tsyringe";
@injectable()
export class LocationLifecycleService {
protected inRaidConfig: IInRaidConfig;
protected traderConfig: ITraderConfig;
protected ragfairConfig: IRagfairConfig;
protected hideoutConfig: IHideoutConfig;
protected locationConfig: ILocationConfig;
protected pmcConfig: IPmcConfig;
constructor(
@inject("PrimaryLogger") protected logger: ILogger,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("SaveServer") protected saveServer: SaveServer,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("DatabaseService") protected databaseService: DatabaseService,
@inject("InRaidHelper") protected inRaidHelper: InRaidHelper,
@inject("HealthHelper") protected healthHelper: HealthHelper,
@inject("QuestHelper") protected questHelper: QuestHelper,
@inject("MatchBotDetailsCacheService") protected matchBotDetailsCacheService: MatchBotDetailsCacheService,
@inject("PmcChatResponseService") protected pmcChatResponseService: PmcChatResponseService,
@inject("PlayerScavGenerator") protected playerScavGenerator: PlayerScavGenerator,
@inject("TraderHelper") protected traderHelper: TraderHelper,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("InsuranceService") protected insuranceService: InsuranceService,
@inject("BotLootCacheService") protected botLootCacheService: BotLootCacheService,
@inject("ConfigServer") protected configServer: ConfigServer,
@inject("BotGenerationCacheService") protected botGenerationCacheService: BotGenerationCacheService,
@inject("MailSendService") protected mailSendService: MailSendService,
@inject("RaidTimeAdjustmentService") protected raidTimeAdjustmentService: RaidTimeAdjustmentService,
@inject("BotNameService") protected botNameService: BotNameService,
@inject("LootGenerator") protected lootGenerator: LootGenerator,
@inject("ApplicationContext") protected applicationContext: ApplicationContext,
@inject("LocationLootGenerator") protected locationLootGenerator: LocationLootGenerator,
@inject("PrimaryCloner") protected cloner: ICloner,
) {
this.inRaidConfig = this.configServer.getConfig(ConfigTypes.IN_RAID);
this.traderConfig = this.configServer.getConfig(ConfigTypes.TRADER);
this.ragfairConfig = this.configServer.getConfig(ConfigTypes.RAGFAIR);
this.hideoutConfig = this.configServer.getConfig(ConfigTypes.HIDEOUT);
this.locationConfig = this.configServer.getConfig(ConfigTypes.LOCATION);
this.pmcConfig = this.configServer.getConfig(ConfigTypes.PMC);
}
/** Handle client/match/local/start */
public startLocalRaid(sessionId: string, request: IStartLocalRaidRequestData): IStartLocalRaidResponseData {
const playerProfile = this.profileHelper.getPmcProfile(sessionId);
const result: IStartLocalRaidResponseData = {
serverId: `${request.location}.${request.playerSide}.${this.timeUtil.getTimestamp()}`, // TODO - does this need to be more verbose - investigate client?
serverSettings: this.databaseService.getLocationServices(), // TODO - is this per map or global?
profile: { insuredItems: playerProfile.InsuredItems },
locationLoot: this.generateLocationAndLoot(request.location, !request.sptSkipLootGeneration),
transition: {
isLocationTransition: false,
transitionRaidId: "66f5750951530ca5ae09876d",
transitionCount: 0,
visitedLocations: [],
},
};
// Only has value when transitioning into map from previous one
if (request.transition) {
// TODO - why doesnt the second raid after transit have any transition data?
result.transition = request.transition;
}
// Get data stored at end of previous raid (if any)
const transitionData = this.applicationContext
.getLatestValue(ContextVariableType.TRANSIT_INFO)
?.getValue<ILocationTransit>();
if (transitionData) {
result.transition.isLocationTransition = true;
result.transition.transitionRaidId = transitionData.transitionRaidId;
result.transition.transitionCount += 1;
result.transition.visitedLocations.push(transitionData.location); // TODO - check doesnt exist before adding to prevent dupes
// Complete, clean up
this.applicationContext.clearValues(ContextVariableType.TRANSIT_INFO);
}
// Apply changes from pmcConfig to bot hostility values
this.adjustBotHostilitySettings(result.locationLoot);
this.adjustExtracts(request.playerSide, request.location, result.locationLoot);
// Clear bot cache ready for a fresh raid
this.botGenerationCacheService.clearStoredBots();
this.botNameService.clearNameCache();
return result;
}
/**
* Replace map exits with scav exits when player is scavving
* @param playerSide Playders side (savage/usec/bear)
* @param location id of map being loaded
* @param locationData Maps locationbase data
*/
protected adjustExtracts(playerSide: string, location: string, locationData: ILocationBase): void {
const playerIsScav = playerSide.toLowerCase() === "savage";
if (playerIsScav) {
// Get relevant extract data for map
const mapExtracts = this.databaseService.getLocation(location)?.allExtracts;
if (!mapExtracts) {
this.logger.warning(`Unable to find map: ${location} extract data, no adjustments made`);
return;
}
// Find only scav extracts and overwrite existing exits with them
const scavExtracts = mapExtracts.filter((extract) => ["scav"].includes(extract.Side.toLowerCase()));
if (scavExtracts.length > 0) {
// Scav extracts found, use them
locationData.exits.push(...scavExtracts);
}
}
}
/**
* Adjust the bot hostility values prior to entering a raid
* @param location map to adjust values of
*/
protected adjustBotHostilitySettings(location: ILocationBase): void {
for (const botId in this.pmcConfig.hostilitySettings) {
const configHostilityChanges = this.pmcConfig.hostilitySettings[botId];
const locationBotHostilityDetails = location.BotLocationModifier.AdditionalHostilitySettings.find(
(botSettings) => botSettings.BotRole.toLowerCase() === botId,
);
// No matching bot in config, skip
if (!locationBotHostilityDetails) {
this.logger.warning(
`No bot: ${botId} hostility values found on: ${location.Id}, can only edit existing. Skipping`,
);
continue;
}
// Add new permanent enemies if they don't already exist
if (configHostilityChanges.additionalEnemyTypes) {
for (const enemyTypeToAdd of configHostilityChanges.additionalEnemyTypes) {
if (!locationBotHostilityDetails.AlwaysEnemies.includes(enemyTypeToAdd)) {
locationBotHostilityDetails.AlwaysEnemies.push(enemyTypeToAdd);
}
}
}
// Add/edit chance settings
if (configHostilityChanges.chancedEnemies) {
for (const chanceDetailsToApply of configHostilityChanges.chancedEnemies) {
const locationBotDetails = locationBotHostilityDetails.ChancedEnemies.find(
(botChance) => botChance.Role === chanceDetailsToApply.Role,
);
if (locationBotDetails) {
// Existing
locationBotDetails.EnemyChance = chanceDetailsToApply.EnemyChance;
} else {
// Add new
locationBotHostilityDetails.ChancedEnemies.push(chanceDetailsToApply);
}
}
}
// Add new permanent friends if they don't already exist
if (configHostilityChanges.additionalFriendlyTypes) {
for (const friendlyTypeToAdd of configHostilityChanges.additionalFriendlyTypes) {
if (!locationBotHostilityDetails.AlwaysFriends.includes(friendlyTypeToAdd)) {
locationBotHostilityDetails.AlwaysFriends.push(friendlyTypeToAdd);
}
}
}
// Adjust vs bear hostility chance
if (typeof configHostilityChanges.bearEnemyChance !== "undefined") {
locationBotHostilityDetails.BearEnemyChance = configHostilityChanges.bearEnemyChance;
}
// Adjust vs usec hostility chance
if (typeof configHostilityChanges.usecEnemyChance !== "undefined") {
locationBotHostilityDetails.UsecEnemyChance = configHostilityChanges.usecEnemyChance;
}
// Adjust vs savage hostility chance
if (typeof configHostilityChanges.savageEnemyChance !== "undefined") {
locationBotHostilityDetails.SavageEnemyChance = configHostilityChanges.savageEnemyChance;
}
// Adjust vs scav hostility behaviour
if (typeof configHostilityChanges.savagePlayerBehaviour !== "undefined") {
locationBotHostilityDetails.SavagePlayerBehaviour = configHostilityChanges.savagePlayerBehaviour;
}
}
}
/**
* Generate a maps base location (cloned) and loot
* @param name Map name
* @param generateLoot OPTIONAL - Should loot be generated for the map before being returned
* @returns ILocationBase
*/
protected generateLocationAndLoot(name: string, generateLoot = true): ILocationBase {
const location = this.databaseService.getLocation(name);
const locationBaseClone = this.cloner.clone(location.base);
// Update datetime property to now
locationBaseClone.UnixDateTime = this.timeUtil.getTimestamp();
// Don't generate loot for hideout
if (name === "hideout") {
return locationBaseClone;
}
// We only need the base data
if (!generateLoot) {
return locationBaseClone;
}
// Check for a loot multipler adjustment in app context and apply if one is found
let locationConfigClone: ILocationConfig;
const raidAdjustments = this.applicationContext
.getLatestValue(ContextVariableType.RAID_ADJUSTMENTS)
?.getValue<IRaidChanges>();
if (raidAdjustments) {
locationConfigClone = this.cloner.clone(this.locationConfig); // Clone values so they can be used to reset originals later
this.raidTimeAdjustmentService.makeAdjustmentsToMap(raidAdjustments, locationBaseClone);
}
const staticAmmoDist = this.cloner.clone(location.staticAmmo);
// Create containers and add loot to them
const staticLoot = this.locationLootGenerator.generateStaticContainers(locationBaseClone, staticAmmoDist);
locationBaseClone.Loot.push(...staticLoot);
// Add dynamic loot to output loot
const dynamicLootDistClone = this.cloner.clone(location.looseLoot);
const dynamicSpawnPoints = this.locationLootGenerator.generateDynamicLoot(
dynamicLootDistClone,
staticAmmoDist,
name.toLowerCase(),
);
for (const spawnPoint of dynamicSpawnPoints) {
locationBaseClone.Loot.push(spawnPoint);
}
// Done generating, log results
this.logger.success(
this.localisationService.getText("location-dynamic_items_spawned_success", dynamicSpawnPoints.length),
);
this.logger.success(this.localisationService.getText("location-generated_success", name));
// Reset loot multipliers back to original values
if (raidAdjustments) {
this.logger.debug("Resetting loot multipliers back to their original values");
this.locationConfig.staticLootMultiplier = locationConfigClone.staticLootMultiplier;
this.locationConfig.looseLootMultiplier = locationConfigClone.looseLootMultiplier;
this.applicationContext.clearValues(ContextVariableType.RAID_ADJUSTMENTS);
}
return locationBaseClone;
}
/** Handle client/match/local/end */
public endLocalRaid(sessionId: string, request: IEndLocalRaidRequestData): void {
// Clear bot loot cache
this.botLootCacheService.clearCache();
const fullProfile = this.profileHelper.getFullProfile(sessionId);
const pmcProfile = fullProfile.characters.pmc;
const scavProfile = fullProfile.characters.scav;
// TODO:
// Quest status?
// stats/eft/aggressor - weird values (EFT.IProfileDataContainer.Nickname)
this.logger.debug(`Raid outcome: ${request.results.result}`);
// Reset flea interval time to out-of-raid value
this.ragfairConfig.runIntervalSeconds = this.ragfairConfig.runIntervalValues.outOfRaid;
this.hideoutConfig.runIntervalSeconds = this.hideoutConfig.runIntervalValues.outOfRaid;
// ServerId has various info stored in it, delimited by a period
const serverDetails = request.serverId.split(".");
const locationName = serverDetails[0].toLowerCase();
const isPmc = serverDetails[1].toLowerCase() === "pmc";
const mapBase = this.databaseService.getLocation(locationName).base;
const isDead = this.isPlayerDead(request.results);
const isTransfer = this.isMapToMapTransfer(request.results);
const isSurvived = this.isPlayerSurvived(request.results);
// Handle items transferred via BTR or transit to player mailbox
this.handleItemTransferEvent(sessionId, request);
// Player is moving between maps
if (isTransfer) {
// Store transfer data for later use in `startLocalRaid()` when next raid starts
this.applicationContext.addValue(ContextVariableType.TRANSIT_INFO, request.locationTransit);
}
if (!isPmc) {
this.handlePostRaidPlayerScav(sessionId, pmcProfile, scavProfile, isDead, isTransfer, request);
return;
}
this.handlePostRaidPmc(
sessionId,
pmcProfile,
scavProfile,
isDead,
isSurvived,
isTransfer,
request,
locationName,
);
// Handle car extracts
if (this.extractWasViaCar(request.results.exitName)) {
this.handleCarExtract(request.results.exitName, pmcProfile, sessionId);
}
// Handle coop exit
if (
request.results.exitName &&
this.extractTakenWasCoop(request.results.exitName) &&
this.traderConfig.fence.coopExtractGift.sendGift
) {
this.handleCoopExtract(sessionId, pmcProfile, request.results.exitName);
this.sendCoopTakenFenceMessage(sessionId);
}
}
/**
* Was extract by car
* @param extractName name of extract
* @returns True if extract was by car
*/
protected extractWasViaCar(extractName: string): boolean {
// exit name is undefined on death
if (!extractName) {
return false;
}
if (extractName.toLowerCase().includes("v-ex")) {
return true;
}
return this.inRaidConfig.carExtracts.includes(extractName.trim());
}
/**
* Handle when a player extracts using a car - Add rep to fence
* @param extractName name of the extract used
* @param pmcData Player profile
* @param sessionId Session id
*/
protected handleCarExtract(extractName: string, pmcData: IPmcData, sessionId: string): void {
// Ensure key exists for extract
if (!(extractName in pmcData.CarExtractCounts)) {
pmcData.CarExtractCounts[extractName] = 0;
}
// Increment extract count value
pmcData.CarExtractCounts[extractName] += 1;
// Not exact replica of Live behaviour
// Simplified for now, no real reason to do the whole (unconfirmed) extra 0.01 standing per day regeneration mechanic
const newFenceStanding = this.getFenceStandingAfterExtract(
pmcData,
this.inRaidConfig.carExtractBaseStandingGain,
pmcData.CarExtractCounts[extractName],
);
const fenceId: string = Traders.FENCE;
pmcData.TradersInfo[fenceId].standing = newFenceStanding;
// Check if new standing has leveled up trader
this.traderHelper.lvlUp(fenceId, pmcData);
pmcData.TradersInfo[fenceId].loyaltyLevel = Math.max(pmcData.TradersInfo[fenceId].loyaltyLevel, 1);
this.logger.debug(
`Car extract: ${extractName} used, total times taken: ${pmcData.CarExtractCounts[extractName]}`,
);
// Copy updated fence rep values into scav profile to ensure consistency
const scavData: IPmcData = this.profileHelper.getScavProfile(sessionId);
scavData.TradersInfo[fenceId].standing = pmcData.TradersInfo[fenceId].standing;
scavData.TradersInfo[fenceId].loyaltyLevel = pmcData.TradersInfo[fenceId].loyaltyLevel;
}
/**
* Handle when a player extracts using a coop extract - add rep to fence
* @param sessionId Session/player id
* @param pmcData Profile
* @param extractName Name of extract taken
*/
protected handleCoopExtract(sessionId: string, pmcData: IPmcData, extractName: string): void {
pmcData.CoopExtractCounts ||= {};
// Ensure key exists for extract
if (!(extractName in pmcData.CoopExtractCounts)) {
pmcData.CoopExtractCounts[extractName] = 0;
}
// Increment extract count value
pmcData.CoopExtractCounts[extractName] += 1;
// Get new fence standing value
const newFenceStanding = this.getFenceStandingAfterExtract(
pmcData,
this.inRaidConfig.coopExtractBaseStandingGain,
pmcData.CoopExtractCounts[extractName],
);
const fenceId: string = Traders.FENCE;
pmcData.TradersInfo[fenceId].standing = newFenceStanding;
// Check if new standing has leveled up trader
this.traderHelper.lvlUp(fenceId, pmcData);
pmcData.TradersInfo[fenceId].loyaltyLevel = Math.max(pmcData.TradersInfo[fenceId].loyaltyLevel, 1);
// Copy updated fence rep values into scav profile to ensure consistency
const scavData: IPmcData = this.profileHelper.getScavProfile(sessionId);
scavData.TradersInfo[fenceId].standing = pmcData.TradersInfo[fenceId].standing;
scavData.TradersInfo[fenceId].loyaltyLevel = pmcData.TradersInfo[fenceId].loyaltyLevel;
}
/**
* Get the fence rep gain from using a car or coop extract
* @param pmcData Profile
* @param baseGain amount gained for the first extract
* @param extractCount Number of times extract was taken
* @returns Fence standing after taking extract
*/
protected getFenceStandingAfterExtract(pmcData: IPmcData, baseGain: number, extractCount: number): number {
// Get current standing
const fenceId: string = Traders.FENCE;
let fenceStanding = Number(pmcData.TradersInfo[fenceId].standing);
// get standing after taking extract x times, x.xx format, gain from extract can be no smaller than 0.01
fenceStanding += Math.max(baseGain / extractCount, 0.01);
// Ensure fence loyalty level is not above/below the range -7 to 15
const newFenceStanding = Math.min(Math.max(fenceStanding, -7), 15);
this.logger.debug(`Old vs new fence standing: ${pmcData.TradersInfo[fenceId].standing}, ${newFenceStanding}`);
return Number(newFenceStanding.toFixed(2));
}
protected sendCoopTakenFenceMessage(sessionId: string): void {
// Generate reward for taking coop extract
const loot = this.lootGenerator.createRandomLoot(this.traderConfig.fence.coopExtractGift);
const mailableLoot: IItem[] = [];
const parentId = this.hashUtil.generate();
for (const item of loot) {
item.parentId = parentId;
mailableLoot.push(item);
}
// Send message from fence giving player reward generated above
this.mailSendService.sendLocalisedNpcMessageToPlayer(
sessionId,
this.traderHelper.getTraderById(Traders.FENCE),
MessageType.MESSAGE_WITH_ITEMS,
this.randomUtil.getArrayValue(this.traderConfig.fence.coopExtractGift.messageLocaleIds),
mailableLoot,
this.timeUtil.getHoursAsSeconds(this.traderConfig.fence.coopExtractGift.giftExpiryHours),
);
}
/**
* Did player take a COOP extract
* @param extractName Name of extract player took
* @returns True if coop extract
*/
protected extractTakenWasCoop(extractName: string): boolean {
// No extract name, not a coop extract
if (!extractName) {
return false;
}
return this.inRaidConfig.coopExtracts.includes(extractName.trim());
}
protected handlePostRaidPlayerScav(
sessionId: string,
pmcProfile: IPmcData,
scavProfile: IPmcData,
isDead: boolean,
isTransfer: boolean,
request: IEndLocalRaidRequestData,
): void {
const postRaidProfile = request.results.profile;
if (isTransfer) {
// We want scav inventory to persist into next raid when pscav is moving between maps
this.inRaidHelper.setInventory(sessionId, scavProfile, postRaidProfile, true, isTransfer);
}
scavProfile.Info.Level = request.results.profile.Info.Level;
scavProfile.Skills = request.results.profile.Skills;
scavProfile.Stats = request.results.profile.Stats;
scavProfile.Encyclopedia = request.results.profile.Encyclopedia;
scavProfile.TaskConditionCounters = request.results.profile.TaskConditionCounters;
scavProfile.SurvivorClass = request.results.profile.SurvivorClass;
// Scavs dont have achievements, but copy anyway
scavProfile.Achievements = request.results.profile.Achievements;
scavProfile.Info.Experience = request.results.profile.Info.Experience;
// Must occur after experience is set and stats copied over
scavProfile.Stats.Eft.TotalSessionExperience = 0;
this.applyTraderStandingAdjustments(scavProfile.TradersInfo, request.results.profile.TradersInfo);
// Clamp fence standing within -7 to 15 range
const currentFenceStanding = request.results.profile.TradersInfo[Traders.FENCE].standing;
scavProfile.TradersInfo[Traders.FENCE].standing = Math.min(Math.max(currentFenceStanding, -7), 15);
// Successful extract as scav, give some rep
if (request.results.ExitStatus === ExitStatus.SURVIVED) {
scavProfile.TradersInfo[Traders.FENCE].standing += this.inRaidConfig.scavExtractStandingGain;
}
// Copy scav fence values to PMC profile
pmcProfile.TradersInfo[Traders.FENCE] = scavProfile.TradersInfo[Traders.FENCE];
// Must occur after encyclopedia updated
this.mergePmcAndScavEncyclopedias(scavProfile, pmcProfile);
// Remove skill fatigue values
this.resetSkillPointsEarnedDuringRaid(scavProfile.Skills.Common);
// Scav died, regen scav loadout and reset timer
if (isDead) {
this.playerScavGenerator.generate(sessionId);
}
// Update last played property
pmcProfile.Info.LastTimePlayedAsSavage = this.timeUtil.getTimestamp();
// Force a profile save
this.saveServer.saveProfile(sessionId);
}
/**
*
* @param sessionId Player id
* @param pmcProfile Pmc profile
* @param scavProfile Scav profile
* @param isDead Player died/got left behind in raid
* @param isSurvived Not same as opposite of `isDead`, specific status
* @param request
* @param locationName
*/
protected handlePostRaidPmc(
sessionId: string,
pmcProfile: IPmcData,
scavProfile: IPmcData,
isDead: boolean,
isSurvived: boolean,
isTransfer: boolean,
request: IEndLocalRaidRequestData,
locationName: string,
): void {
const postRaidProfile = request.results.profile;
const preRaidProfileQuestDataClone = this.cloner.clone(pmcProfile.Quests);
// Update inventory
this.inRaidHelper.setInventory(sessionId, pmcProfile, postRaidProfile, isSurvived, isTransfer);
pmcProfile.Info.Level = postRaidProfile.Info.Level;
pmcProfile.Skills = postRaidProfile.Skills;
pmcProfile.Stats.Eft = postRaidProfile.Stats.Eft;
pmcProfile.Encyclopedia = postRaidProfile.Encyclopedia;
pmcProfile.TaskConditionCounters = postRaidProfile.TaskConditionCounters;
pmcProfile.SurvivorClass = postRaidProfile.SurvivorClass;
pmcProfile.Achievements = postRaidProfile.Achievements;
pmcProfile.Quests = this.processPostRaidQuests(postRaidProfile.Quests);
// Handle edge case - must occur AFTER processPostRaidQuests()
this.lightkeeperQuestWorkaround(sessionId, postRaidProfile.Quests, preRaidProfileQuestDataClone, pmcProfile);
pmcProfile.WishList = postRaidProfile.WishList;
pmcProfile.Info.Experience = postRaidProfile.Info.Experience;
this.applyTraderStandingAdjustments(pmcProfile.TradersInfo, postRaidProfile.TradersInfo);
// Must occur AFTER experience is set and stats copied over
pmcProfile.Stats.Eft.TotalSessionExperience = 0;
const fenceId = Traders.FENCE;
// Clamp fence standing
const currentFenceStanding = postRaidProfile.TradersInfo[fenceId].standing;
pmcProfile.TradersInfo[fenceId].standing = Math.min(Math.max(currentFenceStanding, -7), 15); // Ensure it stays between -7 and 15
// Copy fence values to Scav
scavProfile.TradersInfo[fenceId] = pmcProfile.TradersInfo[fenceId];
// Must occur after encyclopedia updated
this.mergePmcAndScavEncyclopedias(pmcProfile, scavProfile);
// Remove skill fatigue values
this.resetSkillPointsEarnedDuringRaid(pmcProfile.Skills.Common);
// Handle temp, hydration, limb hp/effects
this.healthHelper.updateProfileHealthPostRaid(pmcProfile, postRaidProfile.Health, sessionId, isDead);
if (isDead) {
this.inRaidHelper.removePickupQuestConditions(
postRaidProfile.Stats.Eft.CarriedQuestItems,
sessionId,
pmcProfile,
);
this.pmcChatResponseService.sendKillerResponse(sessionId, pmcProfile, postRaidProfile.Stats.Eft.Aggressor);
this.inRaidHelper.deleteInventory(pmcProfile, sessionId);
this.inRaidHelper.removeFiRStatusFromItemsInContainer(sessionId, pmcProfile, "SecuredContainer");
}
// Must occur AFTER killer messages have been sent
this.matchBotDetailsCacheService.clearCache();
const victims = postRaidProfile.Stats.Eft.Victims.filter(
(victim) => ["pmcbear", "pmcusec"].includes(victim.Role.toLowerCase()), // TODO replace with enum
);
if (victims?.length > 0) {
// Player killed PMCs, send some mail responses to them
this.pmcChatResponseService.sendVictimResponse(sessionId, victims, pmcProfile);
}
this.handleInsuredItemLostEvent(sessionId, pmcProfile, request, locationName);
}
/**
* In 0.15 Lightkeeper quests do not give rewards in PvE, this issue also occurs in spt
* We check for newly completed Lk quests and run them through the servers `CompleteQuest` process
* This rewards players with items + craft unlocks + new trader assorts
* @param sessionId Session id
* @param postRaidQuests Quest statuses post-raid
* @param preRaidQuests Quest statuses pre-raid
* @param pmcProfile Players profile
*/
protected lightkeeperQuestWorkaround(
sessionId: string,
postRaidQuests: IQuestStatus[],
preRaidQuests: IQuestStatus[],
pmcProfile: IPmcData,
): void {
// LK quests that were not completed before raid but now are
const newlyCompletedLightkeeperQuests = postRaidQuests.filter(
(postRaidQuest) =>
postRaidQuest.status === QuestStatus.Success &&
preRaidQuests.find(
(preRaidQuest) =>
preRaidQuest.qid === postRaidQuest.qid && preRaidQuest.status !== QuestStatus.Success,
) &&
this.databaseService.getQuests()[postRaidQuest.qid]?.traderId === Traders.LIGHTHOUSEKEEPER,
);
// Run server complete quest process to ensure player gets rewards
for (const questToComplete of newlyCompletedLightkeeperQuests) {
this.questHelper.completeQuest(
pmcProfile,
{ Action: "CompleteQuest", qid: questToComplete.qid, removeExcessItems: false },
sessionId,
);
}
}
/**
* Convert post-raid quests into correct format
* Quest status comes back as a string version of the enum `Success`, not the expected value of 1
* @param questsToProcess quests data from client
* @param preRaidQuestStatuses quest data from before raid
* @returns IQuestStatus
*/
protected processPostRaidQuests(questsToProcess: IQuestStatus[]): IQuestStatus[] {
for (const quest of questsToProcess) {
quest.status = Number(QuestStatus[quest.status]);
// Iterate over each status timer key and convert from a string into the enums number value
for (const statusTimerKey in quest.statusTimers) {
if (Number.isNaN(Number.parseInt(statusTimerKey))) {
// Is a string, convert
quest.statusTimers[QuestStatus[statusTimerKey]] = quest.statusTimers[statusTimerKey];
// Delete the old string key/value
quest.statusTimers[statusTimerKey] = undefined;
}
}
}
// Find marked as failed quests + flagged as restartable and re-status them as 'failed' so they can be restarted by player
const failedQuests = questsToProcess.filter((quest) => quest.status === QuestStatus.MarkedAsFailed);
for (const failedQuest of failedQuests) {
const dbQuest = this.databaseService.getQuests()[failedQuest.qid];
if (!dbQuest) {
continue;
}
if (dbQuest.restartable) {
failedQuest.status = QuestStatus.Fail;
}
}
return questsToProcess;
}
/**
* Adjust server trader settings if they differ from data sent by client
* @param tradersServerProfile Server
* @param tradersClientProfile Client
*/
protected applyTraderStandingAdjustments(
tradersServerProfile: Record<string, ITraderInfo>,
tradersClientProfile: Record<string, ITraderInfo>,
): void {
for (const traderId in tradersClientProfile) {
const serverProfileTrader = tradersServerProfile[traderId];
const clientProfileTrader = tradersClientProfile[traderId];
if (!(serverProfileTrader && clientProfileTrader)) {
continue;
}
if (clientProfileTrader.standing !== serverProfileTrader.standing) {
// Difference found, update server profile with values from client profile
tradersServerProfile[traderId].standing = clientProfileTrader.standing;
}
}
}
/**
* Check if player used BTR or transit item sending service and send items to player via mail if found
* @param sessionId Session id
* @param request End raid request
*/
protected handleItemTransferEvent(sessionId: string, request: IEndLocalRaidRequestData): void {
const transferTypes = ["btr", "transit"];
for (const trasferType of transferTypes) {
const rootId = `${Traders.BTR}_${trasferType}`;
let itemsToSend = request.transferItems[rootId] ?? [];
// Filter out the btr container item from transferred items before delivering
itemsToSend = itemsToSend.filter((item) => item._id !== Traders.BTR);
if (itemsToSend.length === 0) {
continue;
}
this.transferItemDelivery(sessionId, Traders.BTR, itemsToSend);
}
}
protected transferItemDelivery(sessionId: string, traderId: string, items: IItem[]): void {
const serverProfile = this.saveServer.getProfile(sessionId);
const pmcData = serverProfile.characters.pmc;
const dialogueTemplates = this.databaseService.getTrader(traderId).dialogue;
if (!dialogueTemplates) {
this.logger.error(
this.localisationService.getText("inraid-unable_to_deliver_item_no_trader_found", traderId),
);
return;
}
const messageId = this.randomUtil.getArrayValue(dialogueTemplates.itemsDelivered);
const messageStoreTime = this.timeUtil.getHoursAsSeconds(this.traderConfig.fence.btrDeliveryExpireHours);
// Remove any items that were returned by the item delivery, but also insured, from the player's insurance list
// This is to stop items being duplicated by being returned from both item delivery and insurance
const deliveredItemIds = items.map((item) => item._id);
pmcData.InsuredItems = pmcData.InsuredItems.filter(
(insuredItem) => !deliveredItemIds.includes(insuredItem.itemId),
);
// Send the items to the player
this.mailSendService.sendLocalisedNpcMessageToPlayer(
sessionId,
this.traderHelper.getTraderById(traderId),
MessageType.BTR_ITEMS_DELIVERY,
messageId,
items,
messageStoreTime,
);
}
protected handleInsuredItemLostEvent(
sessionId: string,
preRaidPmcProfile: IPmcData,
request: IEndLocalRaidRequestData,
locationName: string,
): void {
if (request.lostInsuredItems?.length > 0) {
const mappedItems = this.insuranceService.mapInsuredItemsToTrader(
sessionId,
request.lostInsuredItems,
request.results.profile,
);
// Is possible to have items in lostInsuredItems but removed before reaching mappedItems
if (mappedItems.length === 0) {
return;
}
this.insuranceService.storeGearLostInRaidToSendLater(sessionId, mappedItems);
this.insuranceService.startPostRaidInsuranceLostProcess(preRaidPmcProfile, sessionId, locationName);
}
}
/**
* Return the equipped items from a players inventory
* @param items Players inventory to search through
* @returns an array of equipped items
*/
protected getEquippedGear(items: IItem[]): IItem[] {
// Player Slots we care about
const inventorySlots = [
"FirstPrimaryWeapon",
"SecondPrimaryWeapon",
"Holster",
"Scabbard",
"Compass",
"Headwear",
"Earpiece",
"Eyewear",
"FaceCover",
"ArmBand",
"ArmorVest",
"TacticalVest",
"Backpack",
"pocket1",
"pocket2",
"pocket3",
"pocket4",
"SpecialSlot1",
"SpecialSlot2",
"SpecialSlot3",
];
let inventoryItems: IItem[] = [];
// Get an array of root player items
for (const item of items) {
if (inventorySlots.includes(item.slotId)) {
inventoryItems.push(item);
}
}
// Loop through these items and get all of their children
let newItems = inventoryItems;
while (newItems.length > 0) {
const foundItems = [];
for (const item of newItems) {
// Find children of this item
for (const newItem of items) {
if (newItem.parentId === item._id) {
foundItems.push(newItem);
}
}
}
// Add these new found items to our list of inventory items
inventoryItems = [...inventoryItems, ...foundItems];
// Now find the children of these items
newItems = foundItems;
}
return inventoryItems;
}
/**
* Checks to see if player survives. run through will return false
* @param statusOnExit Exit value from offraidData object
* @returns true if Survived
*/
protected isPlayerSurvived(results: IEndRaidResult): boolean {
return results.result.toLowerCase() === "survived";
}
/**
* Is the player dead after a raid - dead = anything other than "survived" / "runner"
* @param results Post raid request
* @returns true if dead
*/
protected isPlayerDead(results: IEndRaidResult): boolean {
return ["killed", "missinginaction", "left"].includes(results.result.toLowerCase());
}
/**
* Has the player moved from one map to another
* @param results Post raid request
* @returns True if players transfered
*/
protected isMapToMapTransfer(results: IEndRaidResult) {
return results.result.toLowerCase() === "transit";
}
/**
* Reset the skill points earned in a raid to 0, ready for next raid
* @param commonSkills Profile common skills to update
*/
protected resetSkillPointsEarnedDuringRaid(commonSkills: Common[]): void {
for (const skill of commonSkills) {
skill.PointsEarnedDuringSession = 0.0;
}
}
/**
* merge two dictionaries together
* Prioritise pair that has true as a value
* @param primary main dictionary
* @param secondary Secondary dictionary
*/
protected mergePmcAndScavEncyclopedias(primary: IPmcData, secondary: IPmcData): void {
function extend(target: { [key: string]: boolean }, source: Record<string, boolean>) {
for (const key in source) {
if (Object.hasOwn(source, key)) {
target[key] = source[key];
}
}
return target;
}
const merged = extend(extend({}, primary.Encyclopedia), secondary.Encyclopedia);
primary.Encyclopedia = merged;
secondary.Encyclopedia = merged;
}
}