/* * The Peacock Project - a HITMAN server replacement. * Copyright (C) 2021-2023 The Peacock Project Team * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. */ import { existsSync, readdirSync, readFileSync } from "fs" import { readdir, readFile, writeFile } from "fs/promises" import * as atomically from "atomically" import { join } from "path" import * as dataGen from "./contracts/dataGen" import { generateUserCentric, getSubLocationFromContract, } from "./contracts/dataGen" import type { Campaign, ClientToServerEvent, CompiledChallengeRuntimeData, ContractSession, GameVersion, GenSingleMissionFunc, GenSingleVideoFunc, IHit, MissionManifest, PeacockLocationsData, PlayNextGetCampaignsHookReturn, RegistryChallenge, RequestWithJwt, S2CEventWithTimestamp, SMFLastDeploy, Unlockable, UserCentricContract, } from "./types/types" import type * as configManagerType from "./configSwizzleManager" import { configs, getConfig, getSwizzleable, getVersionedConfig, swizzle, } from "./configSwizzleManager" import * as logging from "./loggingInterop" import { log, LogLevel } from "./loggingInterop" import * as axios from "axios" import * as ini from "js-ini" import * as statemachineParser from "@peacockproject/statemachine-parser" import * as utils from "./utils" import { addDashesToPublicId, fastClone, getRemoteService, hitmapsUrl, } from "./utils" import * as sessionSerialization from "./sessionSerialization" import * as databaseHandler from "./databaseHandler" import * as playnext from "./menus/playnext" import * as hooksImpl from "./hooksImpl" import { AsyncSeriesHook, SyncBailHook, SyncHook } from "./hooksImpl" import * as hitsCategoryServiceMod from "./contracts/hitsCategoryService" import { MenuSystemDatabase, menuSystemDatabase } from "./menus/menuSystem" import { escalationMappings } from "./contracts/escalationMappings" import { parse } from "json5" import { userAuths } from "./officialServerAuth" // @ts-expect-error Ignore JSON import import LASTYARDBIRDSCPC from "../contractdata/SNIPER/THELASTYARDBIRD_SCPC.json" // @ts-expect-error Ignore JSON import import LEGACYFF from "../contractdata/COLORADO/FREEDOMFIGHTERSLEGACY.json" import { missionsInLocations } from "./contracts/missionsInLocation" import { createContext, Script } from "vm" import { ChallengeService } from "./candle/challengeService" import { getFlag } from "./flags" import { unpack } from "msgpackr" import { ChallengePackage, SavedChallengeGroup } from "./types/challenges" import { promisify } from "util" import { brotliDecompress } from "zlib" import assert from "assert" import { Response } from "express" import { MissionEndRequestQuery } from "./types/gameSchemas" import { ChallengeFilterType } from "./candle/challengeHelpers" import { MasteryService } from "./candle/masteryService" import { MasteryPackage } from "./types/mastery" /** * An array of string arrays that contains the IDs of the featured contracts. * Each of the string arrays is one page. */ export const featuredContractGroups: string[][] = [ [ "6275f476-b90a-4f79-abf3-bf30af020ad8", "ee0411d6-b3e7-4320-b56b-25c45d8a9d61", "a03285bd-7342-4262-b118-df485b5e99a9", "35547610-b036-4025-84f8-18a5db125ea4", "2c9b146a-cad6-48e0-a0d2-55eb7e58dbf7", "2b61a384-b3f7-42cb-b850-4cac06e2eef1", "ecb3e3b9-af4e-40e5-b7d7-9da54715a959", "89936bfe-673c-4b35-9404-72d809620f43", "1447a96c-73bd-4811-9c2e-5588885e36d7", ], [ "ae25aaf1-53c4-4d4d-80b9-609ea09aa8a9", "9272bab9-3322-45c3-bb56-695f9923e27e", "cdc9e2ac-ae52-4ad8-bc85-929c886e4965", "19b1daaf-a472-4cee-9670-304cd62a3307", "57c854f1-92c2-4429-90cb-ebc27cd0f912", "9582fda8-d4c3-4464-955a-497365740ec2", ], [ "7e1e00a0-5e12-4115-bcdb-ddbff1eaa9d1", "6ccce0de-7143-4099-af97-cf9838073f6b", "0db2289d-9035-4c77-a618-d196c4ca4f5c", "7a03a97d-238c-48bd-bda0-e5f279569ccf", "e1795c6a-5728-4bc5-bd71-248bc0071b44", "608830ec-01f2-4606-a904-8acd95f7e112", "4d049c4d-581f-4080-a600-8f1b38937256", "2d26476a-aad3-4a75-b2cd-afaf037876b7", "3b15b095-c278-4633-bb60-9939ce995a2c", "cee4716f-62a5-4290-a5d3-cf7764bf7b4b", "1d33dad8-2d70-4cc6-a1d1-e56c6a9c548c", "1ffb2af4-97c0-446a-add9-2565a358d862", "de89370c-105a-46f7-862f-64c330f28ee9", "1564ba59-58a8-4d20-b73d-63aca3254fa2", "f9c0cc2c-d779-4641-a011-a39734349058", ], [ "5e546c0a-a407-4a17-a226-6cc4674fe987", "9b4636e3-2a6f-4bce-80e1-e3a5f79972b3", "fd3d8751-6298-4a11-9b29-e1bc01c8e08c", "d963e11c-7f49-49c9-a011-8d5d22c0216d", "8484edb5-65a2-4c12-9966-16eee599ee63", ], [ "400562ab-e093-4d42-adb1-3e6fd5dfe99c", "8eed22a6-5bce-4dae-b9ac-5b539acf302e", "5b88babb-d565-4bee-95ec-4d434d49333f", "411b6f6a-a0ac-4ac8-9f9a-dd2c4c273180", "f4a096de-8783-4d97-862e-9db4d032150d", "0b37ea64-f9aa-464f-8cfc-252b58b52e41", "a6d3dc63-9d95-4030-ae90-5f7efce19473", "13d41ab3-4774-4caa-87d1-8d6d31df0423", "fb5cd917-0b3a-464b-8489-8e1f4cc824f9", "4d3a51a8-9cc0-4081-a53e-d1d21c8dbbe8", ], [ "c99273cd-7c1f-4a1c-9b07-e3ceef5ec4cc", "ffd1a594-a3ce-4ee2-95bc-a976c3ee8b46", "857152eb-29ab-419b-946e-f6124a96b34d", "ee0485a3-fead-4a03-8f6a-06d98da31809", "028756e3-f911-4e30-bcda-2e3fa12cd427", "418be72d-89b7-480f-a1ab-3efa88e51cec", ], [ "72d51ef4-3cbd-48ce-9c5a-6502e3313be6", "0244490f-df02-4923-85cb-bafbac351842", "48404607-6f03-4942-9068-7fab4e164dfe", "abc4866f-0837-4708-9ac5-0f5a7a6ca0f6", "bdd6b3dc-f8ae-44cd-8c80-44e018362553", "15cbf4c7-64ab-4dcb-b00d-afab433fe658", "9256f129-4407-4859-8b4d-690026e12b9c", "ab65c0b5-c7da-47aa-82a1-5033126ec18c", "982e023a-5b97-49af-a2e0-91c640dd874b", ], [ "46729d34-489e-4cb1-89f8-30bb1d0df9d1", "b475edbd-50ba-46e5-8c73-78a094129bb6", "1ead714f-9bce-4736-b9cc-3dd5ce75c491", ], [ "f0c1c9e9-f9e1-445b-9611-61546d2aea69", "94836810-aa7b-4472-9ed4-73806c2f303d", "1b2b32ae-798e-4ccc-b353-c3d1c8475faa", "8ba7ffda-7e62-4341-bca3-65b16f2ee6af", "55eb16e8-91a2-48f4-a6f6-141229d220d2", "f8e2aa93-15ca-4a88-b5b7-02f9a712b057", "1878154a-8e23-4fce-908e-cfe26fb67a29", "717aed54-8685-4901-b7bf-118b9780fa36", "ecbd78f6-bc65-4f19-934f-f2bb5adced6d", "001b058d-dea6-42c7-86de-c625a6f87c75", ], ] const peacockRequireTable = { "@peacockproject/core/contracts/dataGen": { __esModule: true, ...dataGen }, "@peacockproject/core/databaseHandler": { __esModule: true, ...databaseHandler, }, "@peacockproject/core/utils": { __esModule: true, ...utils, }, "@peacockproject/core/loggingInterop": { __esModule: true, ...logging }, "@peacockproject/core/sessionSerialization": { __esModule: true, ...sessionSerialization, }, "@peacockproject/statemachine-parser": statemachineParser, "@peacockproject/core/menus/playnext": { __esModule: true, ...playnext, }, "@peacockproject/core/hooksImpl": { __esModule: true, ...hooksImpl, }, "@peacockproject/core/contracts/hitsCategoryService": { __esModule: true, hitsCategoryService: hitsCategoryServiceMod, }, "@peacockproject/core/menus/menuSystem": { __esModule: true, MenuSystemDatabase, menuSystemDatabase, }, axios, ini, atomically, } /** * A binding of the virtual require function that displays the problematic plugin's name. * * @param pluginName The problematic plugin's name. */ function createPeacockRequire(pluginName: string): NodeRequire { /** * A virtual require function for plugins. * * @param specifier The requested module. */ const peacockRequire: NodeRequire = (specifier: string) => { if ( Object.prototype.hasOwnProperty.call(peacockRequireTable, specifier) ) { return peacockRequireTable[specifier] } try { return require(specifier) } catch (e) { log(LogLevel.ERROR, `PRMR: Unable to load ${specifier}.`) log( LogLevel.ERROR, `This is a problem with ${pluginName} - please let the author know.`, ) throw e } } peacockRequire.resolve = require.resolve peacockRequire.cache = require.cache // Yes, we assert that the literal null keyword is not null here. // This is an awful idea in any scenario, except for this one, in which we // control module resolution, and don't need plugins to view/modify either // of these properties. // In short, it's either this, or ts-expect-error. -rdil, Jul 1 2022 // noinspection JSDeprecatedSymbols peacockRequire.extensions = null! peacockRequire.main = null! return peacockRequire } /** * Freedom Fighters for Hitman 2016 (objectives are different). */ export const _legacyBull: MissionManifest = JSON.parse(LEGACYFF) export const _theLastYardbirdScpc: MissionManifest = JSON.parse(LASTYARDBIRDSCPC) export const peacockRecentEscalations: readonly string[] = [ "35f1f534-ae2d-42be-8472-dd55e96625ea", "edbacf4b-e402-4548-b723-cd4351571537", "218302a3-f682-46f9-9ffd-bb3e82487b7c", "9a461f89-86c5-44e4-998e-f2f66b496aa7", ] /** * Ensure a mission has the bare minimum required to work. * * @param m The mission manifest. * @returns If the mission is valid. */ export const validateMission = (m: MissionManifest): boolean => { if (!m.Metadata || !m.Data) { return false } for (const prop of ["Id", "Title", "Location", "ScenePath"]) { if (!Object.prototype.hasOwnProperty.call(m.Metadata, prop)) { log(LogLevel.ERROR, `Contract missing property Metadata.${prop}!`) return false } } for (const prop of ["Objectives", "Bricks"]) { if (!Object.prototype.hasOwnProperty.call(m.Data, prop)) { log(LogLevel.ERROR, `Contract missing property Data.${prop}!`) return false } if (!Array.isArray(m.Data[prop])) { log( LogLevel.ERROR, `Contract property Data.${prop} should be an array (found ${typeof prop})`, ) return false } } return true } const modFrameworkDataPath: string | false = (process.env.LOCALAPPDATA && join( process.env.LOCALAPPDATA, "Simple Mod Framework", "lastDeploy.json", )) || false export class Controller { public hooks: { serverStart: SyncHook<[]> challengesLoaded: SyncHook<[]> masteryDataLoaded: SyncHook<[]> newEvent: SyncHook< [ /** event */ ClientToServerEvent, /** request */ RequestWithJwt, /** session */ ContractSession, ] > newMetricsEvent: SyncHook< [ /** event */ S2CEventWithTimestamp, /** request */ RequestWithJwt<never, S2CEventWithTimestamp[]>, ] > getContractManifest: SyncBailHook< // prettier-ignore [ /** contractId */ string ], MissionManifest | undefined > contributeCampaigns: SyncHook< [ /** campaigns */ Campaign[], /** genSingleMissionFunc */ GenSingleMissionFunc, /** genSingleVideoFunc */ GenSingleVideoFunc, /** gameVersion */ GameVersion, ] > // prettier-ignore getSearchResults: AsyncSeriesHook<[ /** query */ string[], /** contractIds */ string[] ]> getNextCampaignMission: SyncBailHook< // prettier-ignore [ /** contractId */ string, /** gameVersion */ GameVersion ], PlayNextGetCampaignsHookReturn | undefined > getMissionEnd: SyncBailHook< [ /** req */ RequestWithJwt<MissionEndRequestQuery>, /** res */ Response, ], boolean > } public escalationMappings = escalationMappings public configManager: typeof configManagerType = { getConfig, configs, getSwizzleable, getVersionedConfig, swizzle, } public missionsInLocations = missionsInLocations /** * Note: if you are adding a contract, please use {@link addMission}! */ public contracts: Map<string, MissionManifest> = new Map() // Converts a contract's ID to public ID. public contractIdToPublicId: Map<string, string> = new Map() public challengeService: ChallengeService public masteryService: MasteryService /** * A list of Simple Mod Framework mods installed. */ public readonly installedMods: readonly string[] private _pubIdToContractId: Map<string, string> = new Map() private _internalContracts: MissionManifest[] /** Internal elusive target contracts - only accessible during bootstrap. */ private _internalElusives: MissionManifest[] | undefined /** * The constructor. */ public constructor() { this.hooks = { serverStart: new SyncHook(), challengesLoaded: new SyncHook(), masteryDataLoaded: new SyncHook(), newEvent: new SyncHook(), newMetricsEvent: new SyncHook(), getContractManifest: new SyncBailHook(), contributeCampaigns: new SyncHook(), getSearchResults: new AsyncSeriesHook(), getNextCampaignMission: new SyncBailHook(), getMissionEnd: new SyncBailHook(), } if (modFrameworkDataPath && existsSync(modFrameworkDataPath)) { this.installedMods = ( parse( readFileSync(modFrameworkDataPath!).toString(), ) as SMFLastDeploy )?.loadOrder as readonly string[] return } this.installedMods = [] } /** * You should use {@link modIsInstalled} instead! * * Returns whether a mod is UNAVAILABLE. * * @param modId The mod's ID. * @returns If the mod is unavailable. You should probably abort initialization if true is returned. Also returns true if the `overrideFrameworkChecks` flag is set. * @deprecated since v5.5.0 */ public addClientSideModDependency(modId: string): boolean { return ( !this.installedMods.includes(modId) || getFlag("overrideFrameworkChecks") === true ) } /** * Returns whether a mod is available and installed. * * @param modId The mod's ID. * @returns If the mod is available (or the `overrideFrameworkChecks` flag is set). You should probably abort initialisation if false is returned. */ public modIsInstalled(modId: string): boolean { return ( this.installedMods.includes(modId) || getFlag("overrideFrameworkChecks") === true ) } /** * Starts the service and loads in all contracts. * * @throws {Error} If all hope is lost. (In theory, this should never happen) */ async boot(pluginDevHost: boolean): Promise<void> { // this should never actually be hit, but it makes IntelliJ not // complain that it's unused, so... if (!this.configManager) { throw new Error("All hope is lost.") } log( LogLevel.INFO, "Booting Peacock internal services - this may take a moment.", ) await this._loadInternalContracts() this.challengeService = new ChallengeService(this) this.masteryService = new MasteryService() this._addElusiveTargets() this.index() if (modFrameworkDataPath && existsSync(modFrameworkDataPath)) { log( LogLevel.INFO, "Simple Mod Framework installed - using the data it outputs.", ) const lastServerSideData = ( parse( readFileSync(modFrameworkDataPath!).toString(), ) as SMFLastDeploy ).lastServerSideStates if (lastServerSideData?.unlockables) { this.configManager.configs["allunlockables"] = lastServerSideData.unlockables.slice(1) } if (lastServerSideData?.contracts) { for (const [contractId, contractData] of Object.entries( lastServerSideData.contracts, )) { this.contracts.set(contractId, contractData) } } if (lastServerSideData?.blobs) { menuSystemDatabase.hooks.getConfig.tap( "SMFBlobs", (name, gameVersion) => { if ( gameVersion === "h3" && (lastServerSideData.blobs[name] || lastServerSideData.blobs[name.slice(1)]) // leading slash is not included in SMF blobs ) { return parse( readFileSync( join( process.env.LOCALAPPDATA, "Simple Mod Framework", "blobs", lastServerSideData.blobs[name] || lastServerSideData.blobs[ name.slice(1) ], ), ).toString(), ) } }, ) } } await this._loadPlugins() if (pluginDevHost) { await this._loadWorkspacePlugins() } this.hooks.serverStart.call() try { await this._loadResources() this.hooks.challengesLoaded.call() this.hooks.masteryDataLoaded.call() } catch (e) { log(LogLevel.ERROR, `Fatal error with challenge bootstrap: ${e}`) log(LogLevel.ERROR, e.stack) } } /** * Gets a contract from the registry by its public ID, * or downloads it from the official servers if possible. * * @param pubId The contract's public ID. * @param currentUserId The current user's ID. * @param gameVersion The current game version. * @returns The mission manifest or null if it couldn't be resolved. */ public async contractByPubId( pubId: string, currentUserId: string, gameVersion: GameVersion, ): Promise<MissionManifest | undefined> { if (!this._pubIdToContractId.has(pubId)) { return await this.downloadContract( currentUserId, pubId, gameVersion, ) } if (this.contracts.has(this._pubIdToContractId.get(pubId)!)) { return ( this.contracts.get(this._pubIdToContractId.get(pubId)!) || undefined ) } return undefined } /** * Saves a new contract to the contracts folder, and makes it resolve properly, * without triggering a hot module reload. * * @param manifest The contract's data (mission manifest, a.k.a. mission JSON). * @return The manifest that got passed, in case you want to use chaining. */ public async commitNewContract( manifest: MissionManifest, ): Promise<MissionManifest> { const j = JSON.stringify(manifest, undefined, 4) log( LogLevel.INFO, `Saving generated contract ${manifest.Metadata.Id} to contracts/${manifest.Metadata.PublicId}.json`, ) const name = `contracts/${manifest.Metadata.PublicId}.json` await writeFile(name, j) this.index() return manifest } /** * Get a contract by its ID. * * Order of precedence: * 1. Plugins ({@link addMission} or the `getMissionManifest` hook) * 2. Peacock internal contracts storage * 3. Files in the `contracts` folder. * * @param id The contract's ID. * @returns The mission manifest object, or undefined if it wasn't found. */ public resolveContract(id: string): MissionManifest | undefined { if (!id) { return undefined } const optionalPluginJson = this.hooks.getContractManifest.call(id) if (optionalPluginJson) { return fastClone(optionalPluginJson) } const registryJson = this._internalContracts.find( (j) => j.Metadata.Id === id, ) if (registryJson) { const dereferenced: MissionManifest = fastClone(registryJson) if (registryJson.Metadata.Type === "elusive") { dereferenced.Metadata.Type = "mission" return dereferenced } return dereferenced } const openCtJson = this.contracts.has(id) ? this.contracts.get(id) : undefined if (openCtJson) { return fastClone(openCtJson) } return undefined } /** * Adds the specified mission manifest as a mission. * It will be prioritized over all internal missions, escalations, and contracts. * * @param manifest The mission's manifest. */ public addMission(manifest: MissionManifest): void { if (!validateMission(manifest)) { return } this.hooks.getContractManifest.tap( `RegisterContract: ${manifest.Metadata.Id}`, (id) => { if (id === manifest.Metadata.Id) { return manifest } return undefined }, ) } /** * Adds an escalation to the game. * * @param groupId The escalation group ID. All levels must have the `Metadata.InGroup` value set to this! * @param locationId The location of the escalation's ID. * @param levels The escalation's levels. */ public addEscalation( groupId: string, locationId: string, ...levels: MissionManifest[] ): void { const fixedLevels = [...levels].filter(Boolean) fixedLevels.forEach((level) => this.addMission(level)) if (!this.missionsInLocations.escalations[locationId]) { this.missionsInLocations.escalations[locationId] = [] } this.missionsInLocations.escalations[locationId].push(groupId) const escalationGroup = {} let i = 0 while (i + 1 <= fixedLevels.length) { escalationGroup[i + 1] = fixedLevels[i].Metadata.Id i++ } this.escalationMappings[groupId] = escalationGroup } /** * Downloads a contract from the IOI servers. * * @param userId The current user's ID. * @param pubId The public ID (numeric ID) of the contract. * @param gameVersion The game version (IOI's servers are version-dependent). */ public async downloadContract( userId: string, pubId: string, gameVersion: GameVersion, ): Promise<MissionManifest | undefined> { log( LogLevel.DEBUG, `User ${userId} is downloading contract ${pubId}...`, ) let contractData: MissionManifest | undefined = undefined if ( gameVersion === "h3" && getFlag("legacyContractDownloader") !== true ) { const result = await Controller._hitmapsFetchContract(pubId) if (result) { contractData = result } else { log( LogLevel.WARN, `Failed to download from HITMAP servers. Trying official servers instead...`, ) } } if (!contractData) { contractData = await Controller._officialFetchContract( pubId, gameVersion, userId, ) } if (!contractData) { log(LogLevel.ERROR, `No contract data for ${pubId}.`) return undefined } contractData.Metadata.CreatorUserId = "fadb923c-e6bb-4283-a537-eb4d1150262e" await writeFile( `contracts/${pubId}.json`, JSON.stringify(contractData, undefined, 4), ) await this.commitNewContract(contractData) if (PEACOCK_DEV) { log(LogLevel.DEBUG, `Saved contract to contracts/${pubId}.json`) } return contractData } /** * Index all installed contract files (OCREs). * * @internal */ index(): void { this.contracts.clear() this._pubIdToContractId.clear() const contracts = readdirSync("contracts") contracts.forEach((i) => { if (!isContractFile(i)) { return } try { const f = parse( readFileSync(join("contracts", i)).toString(), ) as MissionManifest if (!validateMission(f)) { log(LogLevel.ERROR, `Skipped loading ${i} due to an error!`) return } this.contracts.set(f.Metadata.Id, f) if (f.Metadata.PublicId) { this._pubIdToContractId.set( f.Metadata.PublicId, f.Metadata.Id, ) } } catch (e) { log(LogLevel.ERROR, `Failed to load contract ${i}!`) log(LogLevel.ERROR, e.stack) } }) } /** * Perform late initialization. * * @internal */ _addElusiveTargets(): void { if (getFlag("elusivesAreShown") === true) { this._internalContracts.push( ...this._internalElusives!.map((elusive) => { const e = { ...elusive } assert.ok(e.Data.Objectives, "no objectives on ET") e.Data.Objectives = e.Data.Objectives.map( (missionObjective) => { if ( missionObjective.SuccessEvent?.EventName === "Kill" ) { missionObjective.IsHidden = false } return missionObjective }, ) return e }), ) this._internalElusives = undefined return } this._internalContracts.push(...this._internalElusives!) this._internalElusives = undefined } /** * Fetch a contract from HITMAPS. * * @param publicId The contract's public ID. * @internal */ static async _hitmapsFetchContract( publicId: string, ): Promise<MissionManifest | undefined> { const id = addDashesToPublicId(publicId) type Response = { contract?: { Contract: MissionManifest Location: Unlockable UserCentricContract: UserCentricContract } | null ErrorReason?: string | null } const resp = await axios.default.get<Response>(hitmapsUrl, { params: { publicId: id, }, }) const fetchedData = resp.data const hasData = !!fetchedData?.contract?.Contract if (!hasData) { return undefined } return fetchedData!.contract!.Contract } private async _loadResources(): Promise<void> { // Load challenge resources const challengeDirectory = join( PEACOCK_DEV ? process.cwd() : __dirname, "resources", "challenges", ) await this._handleResources( challengeDirectory, (data: ChallengePackage) => { this._handleChallengeResources(data) }, ) //Get all global challenges and register a simplified version of them { const globalChallenges: RegistryChallenge[] = ( getConfig( "GlobalChallenges", true, ) as CompiledChallengeRuntimeData[] ).map((e) => { const tags = e.Challenge.Tags || [] tags.push("global") //NOTE: Treat all other fields as undefined return <RegistryChallenge>{ Id: e.Challenge.Id, Tags: tags, Name: e.Challenge.Name, ImageName: e.Challenge.ImageName, Description: e.Challenge.Description, Definition: e.Challenge.Definition, Xp: e.Challenge.Xp, InclusionData: e.Challenge.InclusionData, } }) this._handleChallengeResources({ groups: [ <SavedChallengeGroup>{ CategoryId: "global", Challenges: globalChallenges, }, ], meta: { Location: "GLOBAL", }, }) } // Load mastery resources const masteryDirectory = join( PEACOCK_DEV ? process.cwd() : __dirname, "resources", "mastery", ) await this._handleResources( masteryDirectory, (data: MasteryPackage) => { this._handleMasteryResources(data) }, ) } private async _handleResources<T>( directory: string, handleDataCallback: (data: T) => void | Promise<void>, ): Promise<void> { const files = await readdir(directory) for (const file of files) { try { const fileBuffer = await readFile(join(directory, file)) const data: T = unpack(fileBuffer) await handleDataCallback(data) } catch (e) { log(LogLevel.ERROR, `Aborting resource parsing. ${e}`) } } } private _handleChallengeResources(data: ChallengePackage): void { for (const group of data.groups) { if ( [ "UI_MENU_PAGE_PROFILE_CHALLENGES_CATEGORY_ARCADE", "UI_MENU_PAGE_PROFILE_CHALLENGES_CATEGORY_ESCALATION_HM1", "UI_MENU_PAGE_PROFILE_CHALLENGES_CATEGORY_ESCALATION_HM2", ].includes(group.Name) ) { continue } this.challengeService.registerGroup(group, data.meta.Location) for (const challenge of group.Challenges) { this.challengeService.registerChallenge( challenge, group.CategoryId, data.meta.Location, ) } } } private _handleMasteryResources(data: MasteryPackage): void { this.masteryService.registerMasteryData(data) } /** * Fetch a contract from the official servers. * * @param publicId The contract's public ID. * @param gameVersion The game's version. * @param userId The user's ID. * @internal * @private */ private static async _officialFetchContract( publicId: string, gameVersion: GameVersion, userId: string, ): Promise<MissionManifest | undefined> { const remoteService = getRemoteService(gameVersion) const user = userAuths.get(userId) if (!user) { log(LogLevel.WARN, `No authentication for user ${userId}!`) return undefined } const resp = await user._useService<{ data?: { Contract?: MissionManifest } }>( `https://${remoteService}.hitman.io/profiles/page/LookupContractPublicId?publicid=${publicId}`, true, ) const contractData: MissionManifest | undefined = resp.data.data?.Contract return contractData || undefined } /** * Loads all normal, pre-built or pure JS plugins either from root or plugins folder. * * @internal */ private async _loadPlugins(): Promise<void> { if (existsSync("plugins")) { const entries = ( await readdir(join(process.cwd(), "plugins")) ).filter((n) => isPlugin(n, "js") || isPlugin(n, "cjs")) for (const plugin of entries) { const sourceFile = join(process.cwd(), "plugins", plugin) const src = (await readFile(sourceFile)).toString() await this._executePlugin(plugin, src, sourceFile) } } const entries = (await readdir(process.cwd())).filter( (n) => isPlugin(n, "js") || isPlugin(n, "cjs"), ) for (const plugin of entries) { const src = (await readFile(plugin)).toString() await this._executePlugin(plugin, src, join(process.cwd(), plugin)) } } private async _loadWorkspacePlugins(): Promise<void> { const entries = (await readdir(join(process.cwd(), "plugins"))).filter( (n) => isPlugin(n, "ts") || isPlugin(n, "cts"), ) const esbuild = await import("esbuild-wasm") const { transform } = esbuild for (const plugin of entries) { const sourceFile = join(process.cwd(), "plugins", plugin) const raw = (await readFile(sourceFile)).toString() const builtPlugin = await transform(raw, { loader: "ts", sourcemap: "inline", sourcefile: sourceFile, target: "node18", format: "cjs", }) await this._executePlugin(plugin, builtPlugin.code, sourceFile) } } private async _executePlugin( pluginName: string, pluginContents: string, pluginPath: string, ): Promise<void> { const context = createContext({ module: { exports: {} }, exports: {}, process, require: createPeacockRequire(pluginName), }) let theExports try { // eslint-disable-next-line prefer-const theExports = new Script(pluginContents, { filename: pluginPath, }).runInContext(context) } catch (e) { log( LogLevel.ERROR, `Error while attempting to queue plugin ${pluginName} for loading!`, ) log(LogLevel.ERROR, e) log(LogLevel.ERROR, e.stack) return } try { let plugin = theExports if (theExports.__esModule) { // the plugin thinks it's an ES module (incorrectly, as it's in // a CommonJS environment, meaning the plugin was likely written // as a module, and then compiled by a tool), so the actual // function will likely be on the 'default' property plugin = theExports.default ?? theExports } await (plugin as (controller: Controller) => Promise<void>)(this) } catch (e) { log(LogLevel.ERROR, `Error while evaluating plugin ${pluginName}!`) log(LogLevel.ERROR, e) log(LogLevel.ERROR, e.stack) } } private async _loadInternalContracts(): Promise<void> { const decompress = promisify(brotliDecompress) const buf = await readFile( join( PEACOCK_DEV ? process.cwd() : __dirname, "resources", "contracts.br", ), ) const decompressed = JSON.parse((await decompress(buf)).toString()) as { b: MissionManifest[] el: MissionManifest[] } this._internalContracts = decompressed.b this._internalElusives = decompressed.el } public storeIdToPublicId(contracts: UserCentricContract[]): void { contracts.forEach((c) => controller.contractIdToPublicId.set( c.Contract.Metadata.Id, c.Contract.Metadata.PublicId, ), ) } } /** * Returns if the specified file is a OpenContracts contract file. * * @param name The file's name. * @returns If the specified file is an OCRE. */ export function isContractFile(name: string): boolean { return name.endsWith(".ocre") || name.endsWith(".json") } /** * Returns if the specified file is a Peacock plugin file. * * @param name The file's name. * @param extension The target file extension. * @returns If the specified file is a plugin. */ export function isPlugin(name: string, extension: string): boolean { return ( name.endsWith(`.plugin.${extension}`) || // ends with Plugin.js, but isn't just Plugin.js name.endsWith(`Plugin.${extension}`) ) } /** * Returns if the specified repository ID is a suit. * * @param repoId The repository ID. * @param gameVersion The game version. * @returns If the repository ID points to a suit. */ export function isSuit(repoId: string, gameVersion: GameVersion): boolean { const suitsToTypeMap = new Map<string, string>() ;(getVersionedConfig("allunlockables", gameVersion, false) as Unlockable[]) .filter((unlockable) => unlockable.Type === "disguise") .forEach((u) => suitsToTypeMap.set(u.Properties.RepositoryId, u.Subtype), ) return suitsToTypeMap.has(repoId) ? suitsToTypeMap.get(repoId) !== "disguise" : false } /** * Translates a contract ID to a "hit" object. * * @param contractId The contract's ID. * @param gameVersion The game's version. * @param userId The current user's ID. * @returns The hit object. */ export function contractIdToHitObject( contractId: string, gameVersion: GameVersion, userId: string, ): IHit | undefined { const contract = controller.resolveContract(contractId) if (!contract) { throw new Error( "Something went terribly wrong. Please investigate this or inform rdil.", ) } if ( gameVersion === "h1" && contract.Metadata.Location.includes("LOCATION_ICA_FACILITY") ) { contract.Metadata.Location = "LOCATION_ICA_FACILITY" } const subLocation = getSubLocationFromContract(contract, gameVersion) const parentLocation = getVersionedConfig<PeacockLocationsData>( "LocationsData", gameVersion, false, ).parents[subLocation?.Properties?.ParentLocation] // failed to find the location, must be from a newer game if (!subLocation && (gameVersion === "h1" || gameVersion === "h2")) { log( LogLevel.DEBUG, `${contract.Metadata.Location} looks to be from a newer game, skipping (hitObj)!`, ) return undefined } const userCentric = generateUserCentric(contract, userId, gameVersion) if (!userCentric) { log(LogLevel.ERROR, "No UC due to previous error?") return undefined } const challenges = controller.challengeService.getGroupedChallengeLists( { type: ChallengeFilterType.None, }, parentLocation?.Id, ) const challengeCompletion = controller.challengeService.countTotalNCompletedChallenges( challenges, userId, gameVersion, ) return { Id: contract.Metadata.Id, UserCentricContract: userCentric, Location: parentLocation, SubLocation: subLocation, ChallengesCompleted: challengeCompletion.CompletedChallengesCount, ChallengesTotal: challengeCompletion.ChallengesCount, LocationLevel: userCentric.Data.LocationLevel, LocationMaxLevel: userCentric.Data.LocationMaxLevel, LocationCompletion: userCentric.Data.LocationCompletion, LocationXPLeft: userCentric.Data.LocationXpLeft, LocationHideProgression: userCentric.Data.LocationHideProgression, } } /** * Sends an array of publicIds to the contract preservation backend. * @param publicIds The contract publicIds to send. */ export async function preserveContracts(publicIds: string[]): Promise<void> { for (const id of publicIds) { await axios.default.get<Response>(hitmapsUrl, { params: { publicId: addDashesToPublicId(id), }, }) } } export const controller = new Controller()