import { inject, injectable } from "tsyringe"; import { BotHelper } from "@spt-aki/helpers/BotHelper"; import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper"; import { IConfig } from "@spt-aki/models/eft/common/IGlobals"; import { BossLocationSpawn } from "@spt-aki/models/eft/common/ILocationBase"; import { Inventory } from "@spt-aki/models/eft/common/tables/IBotType"; import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; import { SeasonalEventType } from "@spt-aki/models/enums/SeasonalEventType"; import { IHttpConfig } from "@spt-aki/models/spt/config/IHttpConfig"; import { IQuestConfig } from "@spt-aki/models/spt/config/IQuestConfig"; import { ISeasonalEvent, ISeasonalEventConfig } from "@spt-aki/models/spt/config/ISeasonalEventConfig"; import { IWeatherConfig } from "@spt-aki/models/spt/config/IWeatherConfig"; import { ILocationData } from "@spt-aki/models/spt/server/ILocations"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { ConfigServer } from "@spt-aki/servers/ConfigServer"; import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; import { GiftService } from "@spt-aki/services/GiftService"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; import { DatabaseImporter } from "@spt-aki/utils/DatabaseImporter"; @injectable() export class SeasonalEventService { protected seasonalEventConfig: ISeasonalEventConfig; protected questConfig: IQuestConfig; protected httpConfig: IHttpConfig; protected weatherConfig: IWeatherConfig; protected halloweenEventActive: boolean = undefined; protected christmasEventActive: boolean = undefined; /** All events active at this point in time */ protected currentlyActiveEvents: SeasonalEventType[] = []; constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("DatabaseImporter") protected databaseImporter: DatabaseImporter, @inject("GiftService") protected giftService: GiftService, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("BotHelper") protected botHelper: BotHelper, @inject("ProfileHelper") protected profileHelper: ProfileHelper, @inject("ConfigServer") protected configServer: ConfigServer, ) { this.seasonalEventConfig = this.configServer.getConfig(ConfigTypes.SEASONAL_EVENT); this.questConfig = this.configServer.getConfig(ConfigTypes.QUEST); this.httpConfig = this.configServer.getConfig(ConfigTypes.HTTP); this.weatherConfig = this.configServer.getConfig(ConfigTypes.WEATHER); this.cacheActiveEvents(); } protected get christmasEventItems(): string[] { return [ "5c1a1e3f2e221602b66cc4c2", // White beard "5df8a6a186f77412640e2e80", // Red bauble "5df8a77486f77412672a1e3f", // Violet bauble "5df8a72c86f77412640e2e83", // Silver bauble "5a43943586f77416ad2f06e2", // Ded moroz hat "5a43957686f7742a2c2f11b0", // Santa hat ]; } protected get halloweenEventItems(): string[] { return [ "635267ab3c89e2112001f826", // Halloween skull mask "634959225289190e5e773b3b", // Pumpkin loot box "59ef13ca86f77445fd0e2483", // Jack'o'lantern helmet "6176a48d732a664031271438", // Faceless mask "5bd071d786f7747e707b93a3", // Jason mask "5bd0716d86f774171822ef4b", // Misha Mayorov mask "5bd06f5d86f77427101ad47c", // Slender mask "6176a40f0b8c0312ac75a3d3", // Ghoul mask "62a5c2c98ec41a51b34739c0", // Hockey player mask "Captain" "62a5c333ec21e50cad3b5dc6", // Hockey player mask "Brawler" "62a5c41e8ec41a51b34739c3", // Hockey player mask "Quiet" ]; } /** * Get an array of christmas items found in bots inventories as loot * @returns array */ public getChristmasEventItems(): string[] { return this.christmasEventItems; } /** * Get an array of halloween items found in bots inventories as loot * @returns array */ public getHalloweenEventItems(): string[] { return this.halloweenEventItems; } public itemIsChristmasRelated(itemTpl: string): boolean { return this.christmasEventItems.includes(itemTpl); } public itemIsHalloweenRelated(itemTpl: string): boolean { return this.halloweenEventItems.includes(itemTpl); } /** * Check if item id exists in christmas or halloween event arrays * @param itemTpl item tpl to check for * @returns */ public itemIsSeasonalRelated(itemTpl: string): boolean { return this.christmasEventItems.includes(itemTpl) || this.halloweenEventItems.includes(itemTpl); } /** * Get an array of seasonal items that should not appear * e.g. if halloween is active, only return christmas items * or, if halloween and christmas are inactive, return both sets of items * @returns array of tpl strings */ public getInactiveSeasonalEventItems(): string[] { const items = []; if (!this.christmasEventEnabled()) { items.push(...this.christmasEventItems); } if (!this.halloweenEventEnabled()) { items.push(...this.halloweenEventItems); } return items; } /** * Is a seasonal event currently active * @returns true if event is active */ public seasonalEventEnabled(): boolean { return this.christmasEventEnabled() || this.halloweenEventEnabled(); } /** * Is christmas event active * @returns true if active */ public christmasEventEnabled(): boolean { return this.christmasEventActive; } /** * is halloween event active * @returns true if active */ public halloweenEventEnabled(): boolean { return this.halloweenEventActive; } /** * Is detection of seasonal events enabled (halloween / christmas) * @returns true if seasonal events should be checked for */ public isAutomaticEventDetectionEnabled(): boolean { return this.seasonalEventConfig.enableSeasonalEventDetection; } /** * Get a dictionary of gear changes to apply to bots for a specific event e.g. Christmas/Halloween * @param eventName Name of event to get gear changes for * @returns bots with equipment changes */ protected getEventBotGear(eventType: SeasonalEventType): Record>> { return this.seasonalEventConfig.eventGear[eventType.toLowerCase()]; } /** * Get the dates each seasonal event starts and ends at * @returns Record with event name + start/end date */ public getEventDetails(): ISeasonalEvent[] { return this.seasonalEventConfig.events; } /** * Look up quest in configs/quest.json * @param questId Quest to look up * @param event event type (Christmas/Halloween/None) * @returns true if related */ public isQuestRelatedToEvent(questId: string, event: SeasonalEventType): boolean { const eventQuestData = this.questConfig.eventQuests[questId]; if (eventQuestData?.season.toLowerCase() === event.toLowerCase()) { return true; } return false; } /** * Handle seasonal events * @param sessionId Players id */ public enableSeasonalEvents(sessionId: string): void { if (this.currentlyActiveEvents) { const globalConfig = this.databaseServer.getTables().globals.config; for (const event of this.currentlyActiveEvents) { this.updateGlobalEvents(sessionId, globalConfig, event); } } } protected cacheActiveEvents(): void { const currentDate = new Date(); const seasonalEvents = this.getEventDetails(); for (const event of seasonalEvents) { const eventStartDate = new Date(currentDate.getFullYear(), event.startMonth - 1, event.startDay); const eventEndDate = new Date(currentDate.getFullYear(), event.endMonth - 1, event.endDay); // Current date is between start/end dates if (currentDate >= eventStartDate && currentDate <= eventEndDate) { this.currentlyActiveEvents.push(SeasonalEventType[event.type]); if (SeasonalEventType[event.type] === SeasonalEventType.CHRISTMAS) { this.christmasEventActive = true; } if (SeasonalEventType[event.type] === SeasonalEventType.HALLOWEEN) { this.halloweenEventActive = true; } } } } /** * Iterate through bots inventory and loot to find and remove christmas items (as defined in SeasonalEventService) * @param botInventory Bots inventory to iterate over * @param botRole the role of the bot being processed */ public removeChristmasItemsFromBotInventory(botInventory: Inventory, botRole: string): void { const christmasItems = this.getChristmasEventItems(); const equipmentSlotsToFilter = ["FaceCover", "Headwear", "Backpack", "TacticalVest"]; const lootContainersToFilter = ["Backpack", "Pockets", "TacticalVest"]; // Remove christmas related equipment for (const equipmentSlotKey of equipmentSlotsToFilter) { if (!botInventory.equipment[equipmentSlotKey]) { this.logger.warning( this.localisationService.getText("seasonal-missing_equipment_slot_on_bot", { equipmentSlot: equipmentSlotKey, botRole: botRole, }), ); } const equipment: Record = botInventory.equipment[equipmentSlotKey]; botInventory.equipment[equipmentSlotKey] = Object.fromEntries( Object.entries(equipment).filter(([index]) => !christmasItems.includes(index)), ); } // Remove christmas related loot from loot containers for (const lootContainerKey of lootContainersToFilter) { if (!botInventory.items[lootContainerKey]) { this.logger.warning( this.localisationService.getText("seasonal-missing_loot_container_slot_on_bot", { lootContainer: lootContainerKey, botRole: botRole, }), ); } botInventory.items[lootContainerKey] = botInventory.items[lootContainerKey].filter((x: string) => !christmasItems.includes(x) ); } } /** * Make adjusted to server code based on the name of the event passed in * @param sessionId Player id * @param globalConfig globals.json * @param eventName Name of the event to enable. e.g. Christmas */ protected updateGlobalEvents(sessionId: string, globalConfig: IConfig, eventType: SeasonalEventType): void { this.logger.success(`${eventType} event is active`); switch (eventType.toLowerCase()) { case SeasonalEventType.HALLOWEEN.toLowerCase(): globalConfig.EventType = globalConfig.EventType.filter((x) => x !== "None"); globalConfig.EventType.push("Halloween"); globalConfig.EventType.push("HalloweenIllumination"); globalConfig.Health.ProfileHealthSettings.DefaultStimulatorBuff = "Buffs_Halloween"; this.addEventGearToBots(eventType); this.adjustZryachiyMeleeChance(); this.enableHalloweenSummonEvent(); this.addEventBossesToMaps(eventType); this.addPumpkinsToScavBackpacks(); this.adjustTraderIcons(eventType); break; case SeasonalEventType.CHRISTMAS.toLowerCase(): globalConfig.EventType = globalConfig.EventType.filter((x) => x !== "None"); globalConfig.EventType.push("Christmas"); this.addEventGearToBots(eventType); this.addGifterBotToMaps(); this.addLootItemsToGifterDropItemsList(); this.enableDancingTree(); this.giveGift(sessionId, "Christmas2022"); this.enableSnow(); break; case SeasonalEventType.NEW_YEARS.toLowerCase(): this.giveGift(sessionId, "NewYear2023"); this.enableSnow(); break; default: // Likely a mod event this.addEventGearToBots(eventType); break; } } protected adjustZryachiyMeleeChance(): void { this.databaseServer.getTables().bots.types.bosszryachiy.chances.equipment.Scabbard = 100; } protected enableHalloweenSummonEvent(): void { this.databaseServer.getTables().globals.config.EventSettings.EventActive = true; } protected addEventBossesToMaps(eventType: SeasonalEventType): void { const botsToAddPerMap = this.seasonalEventConfig.eventBossSpawns[eventType.toLowerCase()]; if (!botsToAddPerMap) { this.logger.warning(`Unable to add ${eventType} bosses, eventBossSpawns is missing`); return; } const mapKeys = Object.keys(botsToAddPerMap) ?? []; for (const mapKey of mapKeys) { const bossesToAdd = botsToAddPerMap[mapKey]; if (!bossesToAdd) { this.logger.warning(`Unable to add ${eventType} bosses to ${mapKey}`); continue; } for (const boss of bossesToAdd) { const mapBosses: BossLocationSpawn[] = this.databaseServer.getTables().locations[mapKey].base.BossLocationSpawn; if (!mapBosses.find((x) => x.BossName === boss.BossName)) { this.databaseServer.getTables().locations[mapKey].base.BossLocationSpawn.push(...bossesToAdd); } } } } /** * Change trader icons to be more event themed (Halloween only so far) * @param eventType What event is active */ protected adjustTraderIcons(eventType: SeasonalEventType): void { switch (eventType.toLowerCase()) { case SeasonalEventType.HALLOWEEN.toLowerCase(): this.httpConfig.serverImagePathOverride["./assets/images/traders/5a7c2ebb86f7746e324a06ab.png"] = "./assets/images/traders/halloween/5a7c2ebb86f7746e324a06ab.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/5ac3b86a86f77461491d1ad8.png"] = "./assets/images/traders/halloween/5ac3b86a86f77461491d1ad8.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/5c06531a86f7746319710e1b.png"] = "./assets/images/traders/halloween/5c06531a86f7746319710e1b.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/59b91ca086f77469a81232e4.png"] = "./assets/images/traders/halloween/59b91ca086f77469a81232e4.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/59b91cab86f77469aa5343ca.png"] = "./assets/images/traders/halloween/59b91cab86f77469aa5343ca.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/59b91cb486f77469a81232e5.png"] = "./assets/images/traders/halloween/59b91cb486f77469a81232e5.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/59b91cbd86f77469aa5343cb.png"] = "./assets/images/traders/halloween/59b91cbd86f77469aa5343cb.png"; this.httpConfig.serverImagePathOverride["./assets/images/traders/579dc571d53a0658a154fbec.png"] = "./assets/images/traders/halloween/579dc571d53a0658a154fbec.png"; break; case SeasonalEventType.CHRISTMAS.toLowerCase(): // TODO: find christmas trader icons break; } this.databaseImporter.loadImages(`${this.databaseImporter.getSptDataPath()}images/`, ["traders"], [ "/files/trader/avatar/", ]); } /** * Add lootble items from backpack into patrol.ITEMS_TO_DROP difficulty property */ protected addLootItemsToGifterDropItemsList(): void { const gifterBot = this.databaseServer.getTables().bots.types.gifter; for (const difficulty in gifterBot.difficulty) { gifterBot.difficulty[difficulty].Patrol.ITEMS_TO_DROP = gifterBot.inventory.items.Backpack.join(", "); } } /** * Read in data from seasonalEvents.json and add found equipment items to bots * @param eventName Name of the event to read equipment in from config */ protected addEventGearToBots(eventType: SeasonalEventType): void { const botGearChanges = this.getEventBotGear(eventType); if (!botGearChanges) { this.logger.warning(this.localisationService.getText("gameevent-no_gear_data", eventType)); return; } // Iterate over bots with changes to apply for (const bot in botGearChanges) { const botToUpdate = this.databaseServer.getTables().bots.types[bot.toLowerCase()]; if (!botToUpdate) { this.logger.warning(this.localisationService.getText("gameevent-bot_not_found", bot)); continue; } // Iterate over each equipment slot change const gearAmendments = botGearChanges[bot]; for (const equipmentSlot in gearAmendments) { // Adjust slots spawn chance to be at least 75% botToUpdate.chances.equipment[equipmentSlot] = Math.max( botToUpdate.chances.equipment[equipmentSlot], 75, ); // Grab gear to add and loop over it const itemsToAdd = gearAmendments[equipmentSlot]; for (const itemTplIdToAdd in itemsToAdd) { botToUpdate.inventory.equipment[equipmentSlot][itemTplIdToAdd] = itemsToAdd[itemTplIdToAdd]; } } } } protected addPumpkinsToScavBackpacks(): void { const assaultBackpack = this.databaseServer.getTables().bots.types.assault.inventory.items.Backpack; assaultBackpack.push("634959225289190e5e773b3b"); assaultBackpack.push("634959225289190e5e773b3b"); assaultBackpack.push("634959225289190e5e773b3b"); assaultBackpack.push("634959225289190e5e773b3b"); assaultBackpack.push("634959225289190e5e773b3b"); } /** * Set Khorovod(dancing tree) chance to 100% on all maps that support it */ protected enableDancingTree(): void { const maps = this.databaseServer.getTables().locations; for (const mapName in maps) { // Skip maps that have no tree if (["hideout", "base", "privatearea"].includes(mapName)) { continue; } const mapData: ILocationData = maps[mapName]; if (mapData?.base?.BotLocationModifier && "KhorovodChance" in mapData.base.BotLocationModifier) { mapData.base.BotLocationModifier.KhorovodChance = 100; } } } /** * Add santa to maps */ protected addGifterBotToMaps(): void { const gifterSettings = this.seasonalEventConfig.gifterSettings; const maps = this.databaseServer.getTables().locations; for (const gifterMapSettings of gifterSettings) { const mapData: ILocationData = maps[gifterMapSettings.map]; mapData.base.BossLocationSpawn.push({ BossName: "gifter", BossChance: gifterMapSettings.spawnChance, BossZone: gifterMapSettings.zones, BossPlayer: false, BossDifficult: "normal", BossEscortType: "gifter", BossEscortDifficult: "normal", BossEscortAmount: "0", Time: -1, TriggerId: "", TriggerName: "", Delay: 0, RandomTimeSpawn: false, }); } } /** * Send gift to player if they'e not already received it * @param playerId Player to send gift to * @param giftkey Key of gift to give */ protected giveGift(playerId: string, giftkey: string): void { if (!this.profileHelper.playerHasRecievedGift(playerId, giftkey)) { this.giftService.sendGiftToPlayer(playerId, giftkey); } } /** * Get the underlying bot type for an event bot e.g. `peacefullZryachiyEvent` will return `bossZryachiy` * @param eventBotRole Event bot role type * @returns Bot role as string */ public getBaseRoleForEventBot(eventBotRole: string): string { return this.seasonalEventConfig.eventBotMapping[eventBotRole]; } public enableSnow(): void { this.weatherConfig.forceWinterEvent = true; } }