1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-29 09:15:11 +01:00
Peacock/components/controller.ts

1331 lines
42 KiB
TypeScript
Raw Normal View History

/*
* 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"
2022-11-13 12:30:41 +01:00
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
>
2022-11-13 12:30:41 +01:00
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(),
2022-11-13 12:30:41 +01:00
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 {
2022-12-22 20:49:04 +01:00
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)
2022-12-22 20:49:04 +01:00
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()