993 lines
42 KiB
TypeScript
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;
|
|
}
|
|
}
|