/* * 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 . */ import { missionEnd } from "./scoreHandler" import { Response, Router } from "express" import { contractCreationTutorialId, gameDifficulty, getMaxProfileLevel, isSniperLocation, isSuit, PEACOCKVERSTRING, unlockOrderComparer, uuidRegex, } from "./utils" import { contractSessions, getSession } from "./eventHandler" import { getConfig, getVersionedConfig } from "./configSwizzleManager" import { contractIdToHitObject, controller } from "./controller" import { makeCampaigns } from "./menus/campaigns" import { createLocationsData, destinationsMenu, getDestinationCompletion, } from "./menus/destinations" import type { ChallengeCategoryCompletion, CommonSelectScreenConfig, ContractSearchResult, GameVersion, HitsCategoryCategory, IHit, MissionManifest, PeacockLocationsData, PlayerProfileView, ProgressionData, RequestWithJwt, SafehouseCategory, SceneConfig, UserCentricContract, } from "./types/types" import { no2016 } from "./contracts/escalations/escalationService" import { complications, generateCompletionData, generateUserCentric, getSubLocationByName, } from "./contracts/dataGen" import { log, LogLevel } from "./loggingInterop" import { contractsModeHome, officialSearchContract, } from "./contracts/contractsModeRouting" import random from "random" import { getUserData } from "./databaseHandler" import { createMainOpportunityTile, createMenuPageTile, createPlayNextTile, getSeasonId, orderedMissions, orderedPZMissions, sniperMissionIds, } from "./menus/playnext" import { randomUUID } from "crypto" import { planningView } from "./menus/planning" import { deleteMultiple, directRoute, withLookupDialog, } from "./menus/favoriteContracts" import { swapToBrowsingMenusStatus } from "./discordRp" import axios from "axios" import { getFlag } from "./flags" import { fakePlayerRegistry } from "./profileHandler" import { createInventory, getUnlockableById } from "./inventory" import { missionsInLocations } from "./contracts/missionsInLocation" import { json as jsonMiddleware } from "body-parser" import { hitsCategoryService } from "./contracts/hitsCategoryService" import { GetCompletionDataForLocationQuery, MasteryUnlockableQuery, MissionEndRequestQuery, StashpointQuery, } from "./types/gameSchemas" import assert from "assert" export const preMenuDataRouter = Router() const menuDataRouter = Router() // /profiles/page/ menuDataRouter.get( "/ChallengeLocation", (req: RequestWithJwt<{ locationId: string }>, res) => { if (typeof req.query.locationId !== "string") { res.status(400).send("Invalid locationId") return } const location = getVersionedConfig( "LocationsData", req.gameVersion, true, ).children[req.query.locationId] res.json({ template: getVersionedConfig( "ChallengeLocationTemplate", req.gameVersion, false, ), data: { Name: location.DisplayNameLocKey, Location: location, Children: controller.challengeService.getChallengeDataForLocation( req.query.locationId, req.gameVersion, req.jwt.unique_name, ), }, }) }, ) menuDataRouter.get("/Hub", (req: RequestWithJwt, res) => { swapToBrowsingMenusStatus(req.gameVersion) const userdata = getUserData(req.jwt.unique_name, req.gameVersion) const theTemplate = req.gameVersion === "h3" ? null : req.gameVersion === "h2" ? null : req.gameVersion === "scpc" ? getConfig("FrankensteinHubTemplate", false) : getConfig("LegacyHubTemplate", false) const contractCreationTutorial = req.gameVersion !== "scpc" ? controller.resolveContract(contractCreationTutorialId)! : undefined const locations = getVersionedConfig( "LocationsData", req.gameVersion, true, ) const career = req.gameVersion === "h3" ? {} : { // TODO: Add data on elusive challenges. They are only shown on the Career->Challenges page for H1 and H2. They are not supported by Peacock as of v6.0.0. ELUSIVES_UNSUPPORTED: { Children: [], Name: "UI_MENU_PAGE_PROFILE_CHALLENGES_CATEGORY_ELUSIVE", Location: locations.parents["LOCATION_PARENT_ICA_FACILITY"], }, } const masteryData = [] for (const parent in locations.parents) { career[parent] = { Children: [], Location: locations.parents[parent], Name: locations.parents[parent].DisplayNameLocKey, } // Exclude ICA Facility from showing in the Career -> Mastery page if (parent === "LOCATION_PARENT_ICA_FACILITY") continue if ( controller.masteryService.getMasteryDataForDestination( parent, req.gameVersion, req.jwt.unique_name, ).length ) { const completionData = controller.masteryService.getLocationCompletion( parent, parent, req.gameVersion, req.jwt.unique_name, parent.includes("SNUG") ? "evergreen" : "mission", req.gameVersion === "h1" ? "normal" : undefined, ) masteryData.push({ CompletionData: completionData, ...(req.gameVersion === "h1" ? { Data: { normal: { CompletionData: completionData, }, pro1: { CompletionData: controller.masteryService.getLocationCompletion( parent, parent, req.gameVersion, req.jwt.unique_name, parent.includes("SNUG") ? "evergreen" : "mission", "pro1", ), }, }, } : {}), Id: locations.parents[parent].Id, Image: locations.parents[parent].Properties.Icon, IsLocked: locations.parents[parent].Properties.IsLocked, Location: locations.parents[parent], RequiredResources: locations.parents[parent].Properties.RequiredResources, }) } } for (const child in locations.children) { if ( child === "LOCATION_ICA_FACILITY_ARRIVAL" || child === "LOCATION_HOKKAIDO_SHIM_MAMUSHI" || child.includes("SNUG_") ) { continue } const parent = locations.children[child].Properties.ParentLocation const location = locations.children[child] const challenges = controller.challengeService.getChallengesForLocation( child, req.gameVersion, ) const challengeCompletion = controller.challengeService.countTotalNCompletedChallenges( challenges, req.jwt.unique_name, req.gameVersion, ) career[parent]?.Children.push({ IsLocked: location.Properties.IsLocked, Name: location.DisplayNameLocKey, Image: location.Properties.Icon, Icon: location.Type, // should be "location" for all locations CompletedChallengesCount: challengeCompletion.CompletedChallengesCount, ChallengesCount: challengeCompletion.ChallengesCount, CategoryId: child, Description: `UI_${child}_PRIMARY_DESC`, Location: location, ImageLocked: location.Properties.LockedIcon, RequiredResources: location.Properties.RequiredResources, IsPack: false, // should be false for all locations CompletionData: generateCompletionData( child, req.jwt.unique_name, req.gameVersion, ), }) } res.json({ template: theTemplate, data: { ServerTile: { title: "The Peacock Project", image: "images/contracts/novikov_and_magolis/tile.jpg", icon: "story", url: "", select: { header: "Playing on a Peacock instance", title: "The Peacock Project", icon: "story", }, }, DashboardData: [], DestinationsData: destinationsMenu(req), CreateContractTutorial: generateUserCentric( contractCreationTutorial, req.jwt.unique_name, req.gameVersion, ), LocationsData: createLocationsData(req.gameVersion, true), ProfileData: { ChallengeData: { Children: Object.values(career), }, MasteryData: masteryData, }, StoryData: makeCampaigns(req.gameVersion, req.jwt.unique_name), FilterData: getVersionedConfig( "FilterData", req.gameVersion, false, ), StoreData: getVersionedConfig("StoreData", req.gameVersion, false), IOIAccountStatus: { IsConfirmed: true, LinkedEmail: "mail@example.com", IOIAccountId: "00000000-0000-0000-0000-000000000000", IOIAccountBaseUrl: "https://account.ioi.dk", }, FinishedFinalTest: true, Currency: { Balance: 0, }, PlayerProfileXpData: { XP: userdata.Extensions.progression.PlayerProfileXP.Total, Level: userdata.Extensions.progression.PlayerProfileXP .ProfileLevel, MaxLevel: getMaxProfileLevel(req.gameVersion), }, }, }) }) menuDataRouter.get("/SafehouseCategory", (req: RequestWithJwt, res) => { const inventory = createInventory(req.jwt.unique_name, req.gameVersion) const safehouseData = { template: req.gameVersion === "h1" ? getConfig("LegacySafehouseTemplate", false) : null, data: { Category: "_root", SubCategories: [], IsLeaf: false, Data: null, } as SafehouseCategory, } for (const item of inventory) { if (req.query.type) { // if type is specified in query if (item.Unlockable.Type !== req.query.type) { continue // skip all items that are not that type } if ( req.query.subtype && item.Unlockable.Subtype !== req.query.subtype ) { // if subtype is specified continue // skip all items that are not that subtype } } else if ( item.Unlockable.Type === "access" || item.Unlockable.Type === "location" || item.Unlockable.Type === "package" || item.Unlockable.Type === "loadoutunlock" || item.Unlockable.Type === "difficultyunlock" || item.Unlockable.Type === "agencypickup" || item.Unlockable.Type === "challengemultiplier" ) { continue // these types should not be displayed when not asked for } else if (item.Unlockable.Properties.InclusionData) { // Only sniper unlockables have inclusion data, don't show them continue } if ( item.Unlockable.Subtype === "disguise" && req.gameVersion === "h3" ) { continue // I don't want to put this in that elif statement } let category = safehouseData.data.SubCategories.find( (cat) => cat.Category === item.Unlockable.Type, ) let subcategory if (!category) { category = { Category: item.Unlockable.Type, SubCategories: [], IsLeaf: false, Data: null, } safehouseData.data.SubCategories.push(category) } subcategory = category.SubCategories.find( (cat) => cat.Category === item.Unlockable.Subtype, ) if (!subcategory) { subcategory = { Category: item.Unlockable.Subtype, SubCategories: null, IsLeaf: true, Data: { Type: item.Unlockable.Type, SubType: item.Unlockable.Subtype, Items: [], Page: 0, HasMore: false, }, } category.SubCategories.push(subcategory) } subcategory.Data.Items.push({ Item: item, ItemDetails: { Capabilities: [], StatList: item.Unlockable.Properties.Gameplay ? Object.entries(item.Unlockable.Properties.Gameplay).map( ([key, value]) => ({ Name: key, Ratio: value, }), ) : [], PropertyTexts: [], }, Type: item.Unlockable.Type, SubType: item.Unlockable.SubType, }) } for (const [id, category] of safehouseData.data.SubCategories.entries()) { if (category.SubCategories.length === 1) { // if category only has one subcategory safehouseData.data.SubCategories[id] = category.SubCategories[0] // flatten it safehouseData.data.SubCategories[id].Category = category.Category // but keep the top category's name } } if (safehouseData.data.SubCategories.length === 1) { // if root has only one subcategory safehouseData.data = safehouseData.data.SubCategories[0] // flatten it } res.json(safehouseData) }) menuDataRouter.get("/report", (req: RequestWithJwt, res) => { res.json({ template: getVersionedConfig("ReportTemplate", req.gameVersion, false), data: { Reasons: [ { Id: 0, Title: "UI_MENU_REPORT_REASON_OFFENSIVE" }, { Id: 1, Title: "UI_MENU_REPORT_REASON_BUGGY" }, ], }, }) }) menuDataRouter.get( "/stashpoint", (req: RequestWithJwt, res) => { // Note: this is handled differently for 2016 // /stashpoint?contractid=e5b6ccf4-1f29-4ec6-bfb8-2e9b78882c85&slotid=4&slotname=gear4&stashpoint=&allowlargeitems=true&allowcontainers=true // /stashpoint?contractid=c1d015b4-be08-4e44-808e-ada0f387656f&slotid=3&slotname=disguise3&stashpoint=&allowlargeitems=true&allowcontainers=true // /stashpoint?contractid=&slotid=3&slotname=disguise&stashpoint=&allowlargeitems=true&allowcontainers=false // /stashpoint?contractid=5b5f8aa4-ecb4-4a0a-9aff-98aa1de43dcc&slotid=6&slotname=stashpoint6&stashpoint=28b03709-d1f0-4388-b207-f03611eafb64&allowlargeitems=true&allowcontainers=false const stashData: { template: unknown data?: { SlotId?: string | number LoadoutItemsData?: unknown UserCentric?: UserCentricContract ShowSlotName?: string | number } } = { template: getVersionedConfig( "StashpointTemplate", req.gameVersion === "h1" ? "h1" : "h3", false, ), } if ( typeof req.query.slotname !== "string" || !(req.query.slotid ?? undefined) ) { res.status(400).send("invalid slot data") return } let contractData: MissionManifest | undefined = undefined if (req.query.contractid) { contractData = controller.resolveContract(req.query.contractid) } const inventory = createInventory( req.jwt.unique_name, req.gameVersion, getSubLocationByName( contractData?.Metadata.Location, req.gameVersion, ), ) if (req.query.slotname.endsWith(req.query.slotid!.toString())) { req.query.slotname = req.query.slotname.slice( 0, -req.query.slotid!.toString().length, ) // weird } stashData.data = { SlotId: req.query.slotid, LoadoutItemsData: { SlotId: req.query.slotid, Items: inventory .filter((item) => { if ( req.query.slotname === "gear" && contractData?.Peacock?.noGear === true ) { return false } if ( req.query.slotname === "concealedweapon" && contractData?.Peacock?.noCarriedWeapon === true ) { return false } if ( item.Unlockable.Subtype === "disguise" && req.gameVersion === "h3" ) { return false } return ( item.Unlockable.Properties.LoadoutSlot && // only display items (!req.query.slotname || ((uuidRegex.test(req.query.slotid as string) || // container req.query.slotname === "stashpoint") && // stashpoint item.Unlockable.Properties.LoadoutSlot !== "disguise") || // container or stashpoint => display all items item.Unlockable.Properties.LoadoutSlot === req.query.slotname) && // else: display items for requested slot (req.query.allowcontainers === "true" || !item.Unlockable.Properties.IsContainer) && (req.query.allowlargeitems === "true" || item.Unlockable.Properties.LoadoutSlot !== "carriedweapon") && item.Unlockable.Type !== "challengemultiplier" && !item.Unlockable.Properties.InclusionData ) // not sure about this one }) .map((item) => ({ Item: item, ItemDetails: { Capabilities: [], StatList: item.Unlockable.Properties.Gameplay ? Object.entries( item.Unlockable.Properties.Gameplay, ).map(([key, value]) => ({ Name: key, Ratio: value, })) : [], PropertyTexts: [], }, SlotId: req.query.slotid, SlotName: null, })), Page: 0, HasMore: false, HasMoreLeft: false, HasMoreRight: false, OptionalData: { stashpoint: req.query.stashpoint || "", AllowLargeItems: req.query.allowlargeitems, AllowContainers: req.query.allowcontainers, // ?? true }, }, ShowSlotName: req.query.slotname, } if (contractData) { stashData.data.UserCentric = generateUserCentric( contractData, req.jwt.unique_name, req.gameVersion, ) } res.json(stashData) }, ) menuDataRouter.get( "/missionrewards", (req: RequestWithJwt<{ contractSessionId: string }>, res) => { const { contractId } = getSession(req.jwt.unique_name) const contractData = controller.resolveContract(contractId, true) const userData = getUserData(req.jwt.unique_name, req.gameVersion) res.json({ template: getConfig("MissionRewardsTemplate", false), data: { LevelInfo: [ 0, 6000, 12000, 18000, 24000, 30000, 36000, 42000, 48000, 54000, 60000, 66000, 72000, 78000, 84000, 90000, 96000, 102000, 108000, 114000, ], XP: 0, Level: 1, Completion: 0, XPGain: 0, Challenges: Object.values( controller.challengeService.getChallengesForContract( contractId, req.gameVersion, req.jwt.unique_name, // TODO: Should a difficulty be passed here? ), ) .flat() // FIXME: This behaviour may not be accurate to original server .filter((challengeData) => controller.challengeService.fastGetIsCompleted( userData, challengeData.Id, ), ) .map((challengeData) => controller.challengeService.compileRegistryChallengeTreeData( challengeData, controller.challengeService.getPersistentChallengeProgression( req.jwt.unique_name, challengeData.Id, req.gameVersion, ), req.gameVersion, req.jwt.unique_name, ), ), Drops: [], ContractCompletionBonus: 0, GroupCompletionBonus: 0, LocationHideProgression: true, Difficulty: "normal", // FIXME: is this right? CompletionData: generateCompletionData( contractData.Metadata.Location, req.jwt.unique_name, req.gameVersion, ), }, }) }, ) menuDataRouter.get( "/scoreoverview", async (req: RequestWithJwt, res) => { const resJsonFunc = res.json res.json = function fakeJsonBind(input) { return resJsonFunc.call(this, { template: getConfig("ScoreOverviewTemplate", false), data: input.data.ScoreOverview, }) } await missionEnd(req, res) }, ) menuDataRouter.get("/Planning", planningView) menuDataRouter.get( "/selectagencypickup", (req: RequestWithJwt<{ contractId: string }>, res) => { const pickupData = getConfig("AgencyPickups", false) const selectagencypickup = { template: getVersionedConfig( "SelectAgencyPickupTemplate", req.gameVersion, false, ), } as CommonSelectScreenConfig const inventory = createInventory(req.jwt.unique_name, req.gameVersion) const contractData = controller.resolveContract(req.query.contractId) if (!contractData) { log( LogLevel.WARN, `Unknown contract on SAP: ${req.query.contractId}`, ) return res.status(404).end() } const scenePath = contractData.Metadata.ScenePath.toLowerCase() log( LogLevel.DEBUG, `Looking up details for contract - Id:${req.query.contractId} (${scenePath})`, ) if (!Object.prototype.hasOwnProperty.call(pickupData, scenePath)) { log( LogLevel.ERROR, `Could not find AgencyPickup data for ${scenePath}! This may cause an unhandled promise rejection.`, ) } if (contractData.Peacock?.noAgencyPickupsActive === true) { selectagencypickup.data = { Unlocked: [], Contract: contractData, OrderedUnlocks: [], UserCentric: generateUserCentric( contractData, req.jwt.unique_name, req.gameVersion, ), } res.json(selectagencypickup) return } const pickupsInScene = pickupData[scenePath] const unlockedAgencyPickups = inventory .filter( (item) => item.Unlockable.Type === "agencypickup" && item.Unlockable.Properties.Difficulty === contractData.Metadata.Difficulty && item.Unlockable.Properties.RepositoryId, ) .map((i) => i.Unlockable) selectagencypickup.data = { Unlocked: unlockedAgencyPickups.map( (unlockable) => unlockable.Properties.RepositoryId!, ), Contract: contractData, OrderedUnlocks: unlockedAgencyPickups .filter((unlockable) => pickupsInScene.includes(unlockable.Properties.RepositoryId), ) .sort(unlockOrderComparer), UserCentric: generateUserCentric( contractData, req.jwt.unique_name, req.gameVersion, ), } res.json(selectagencypickup) }, ) menuDataRouter.get( "/selectentrance", (req: RequestWithJwt<{ contractId: string }>, res) => { const entranceData = getConfig("Entrances", false) const selectEntrance: CommonSelectScreenConfig = { template: getVersionedConfig( "SelectEntranceTemplate", req.gameVersion, true, ), } const inventory = createInventory(req.jwt.unique_name, req.gameVersion) const contractData = controller.resolveContract(req.query.contractId) if (!contractData) { log(LogLevel.WARN, `Unknown contract: ${req.query.contractId}`) return res.status(404).end() } const scenePath = contractData.Metadata.ScenePath.toLowerCase() log( LogLevel.DEBUG, `Looking up details for contract - Id:${req.query.contractId} (${scenePath})`, ) if (!Object.prototype.hasOwnProperty.call(entranceData, scenePath)) { log( LogLevel.ERROR, `Could not find Entrance data for ${scenePath}! This may cause an unhandled promise rejection.`, ) } const entrancesInScene = entranceData[scenePath] const unlockedEntrances = inventory .filter( (item) => item.Unlockable.Subtype === "startinglocation" && item.Unlockable.Properties.Difficulty === contractData.Metadata.Difficulty && item.Unlockable.Properties.RepositoryId, ) .map((i) => i.Unlockable) selectEntrance.data = { Unlocked: unlockedEntrances.map( (unlockable) => unlockable.Properties.RepositoryId!, ), Contract: contractData, OrderedUnlocks: unlockedEntrances .filter((unlockable) => entrancesInScene.includes( unlockable.Properties.RepositoryId!, ), ) .sort(unlockOrderComparer), UserCentric: generateUserCentric( contractData, req.jwt.unique_name, req.gameVersion, ), } res.json(selectEntrance) }, ) menuDataRouter.get("/missionendready", async (req, res) => { const sessionDetails = contractSessions.get( req.query.contractSessionId as string, ) const retryCount = 1 + Number(req.query.retryCount) || 0 if (!sessionDetails?.timerEnd) { // not ready // wait some time proportional to the amount of retries await new Promise((resolve) => setTimeout(() => resolve(undefined), retryCount * 100), ) res.json({ template: getConfig("MissionEndNotReadyTemplate", false), data: { contractSessionId: req.query.contractSessionId, missionEndReady: false, retryCount: retryCount, }, }) } else { // ready res.json({ template: getConfig("MissionEndReadyTemplate", false), data: { contractSessionId: req.query.contractSessionId, missionEndReady: true, retryCount: retryCount, }, }) } }) menuDataRouter.get("/missionend", missionEnd) menuDataRouter.get("/scoreoverviewandunlocks", missionEnd) menuDataRouter.get( "/Destination", (req: RequestWithJwt<{ locationId: string; difficulty?: string }>, res) => { const LOCATION = req.query.locationId const locData = getVersionedConfig( "LocationsData", req.gameVersion, false, ) const locationData = locData.parents[LOCATION] const masteryData = controller.masteryService.getMasteryDataForDestination( req.query.locationId, req.gameVersion, req.jwt.unique_name, req.query.difficulty, ) const response = { template: req.gameVersion === "h1" ? getConfig("LegacyDestinationTemplate", false) : null, data: { Location: {}, MissionData: { ...getDestinationCompletion(locationData, undefined, req), ...{ SubLocationMissionsData: [] }, }, ChallengeData: { Children: controller.challengeService.getChallengeDataForDestination( req.query.locationId, req.gameVersion, req.jwt.unique_name, ), }, MasteryData: LOCATION !== "LOCATION_PARENT_ICA_FACILITY" ? req.gameVersion === "h1" ? masteryData[0] : masteryData : {}, DifficultyData: undefined, }, } if ( req.gameVersion === "h1" && LOCATION !== "LOCATION_PARENT_ICA_FACILITY" ) { const inventory = createInventory( req.jwt.unique_name, req.gameVersion, ) response.data.DifficultyData = { AvailableDifficultyModes: [ { Name: "normal", Available: true, }, { Name: "pro1", Available: inventory.some( (e) => e.Unlockable.Id === locationData.Properties.DifficultyUnlock.pro1, ), }, ], Difficulty: req.query.difficulty, LocationId: LOCATION, } } if (PEACOCK_DEV) { log(LogLevel.DEBUG, `Looking up locations details for ${LOCATION}.`) } const sublocationsData = Object.values(locData.children).filter( (subLocation) => subLocation.Properties.ParentLocation === LOCATION, ) response.data.Location = locationData if (req.query.difficulty === "pro1") { const obj = { Location: locationData, SubLocation: locationData, Missions: [controller.missionsInLocations.pro1[LOCATION]].map( (id) => contractIdToHitObject( id, req.gameVersion, req.jwt.unique_name, ), ), SarajevoSixMissions: [], ElusiveMissions: [], EscalationMissions: [], SniperMissions: [], PlaceholderMissions: [], CampaignMissions: [], CompletionData: generateCompletionData( sublocationsData[0].Id, req.jwt.unique_name, req.gameVersion, ), } response.data.MissionData.SubLocationMissionsData.push(obj) res.json(response) return } for (const e of sublocationsData) { log(LogLevel.DEBUG, `Looking up sublocation details for ${e.Id}`) const escalations: IHit[] = [] // every unique escalation from the sublocation const allUniqueEscalations: string[] = [ ...(req.gameVersion === "h1" && e.Id === "LOCATION_ICA_FACILITY" ? controller.missionsInLocations.escalations[ "LOCATION_ICA_FACILITY_SHIP" ] : []), ...new Set( controller.missionsInLocations.escalations[e.Id] || [], ), ] for (const escalation of allUniqueEscalations) { if (req.gameVersion === "h1" && no2016.includes(escalation)) continue const details = contractIdToHitObject( escalation, req.gameVersion, req.jwt.unique_name, ) if (details) { escalations.push(details) } } const sniperMissions: IHit[] = [] for (const sniperMission of controller.missionsInLocations.sniper[ e.Id ] ?? []) { sniperMissions.push( contractIdToHitObject( sniperMission, req.gameVersion, req.jwt.unique_name, ), ) } const obj = { Location: locationData, SubLocation: e, Missions: [], SarajevoSixMissions: [], ElusiveMissions: [], EscalationMissions: escalations, SniperMissions: sniperMissions, PlaceholderMissions: [], CampaignMissions: [], CompletionData: generateCompletionData( e.Id, req.jwt.unique_name, req.gameVersion, ), } const types = [ ...[ [undefined, "Missions"], ["elusive", "ElusiveMissions"], ], ...((req.gameVersion === "h1" && missionsInLocations.sarajevo["h2016enabled"]) || req.gameVersion === "h3" ? [["sarajevo", "SarajevoSixMissions"]] : []), ] for (const t of types) { let theMissions = !t[0] // no specific type ? controller.missionsInLocations[e.Id] : controller.missionsInLocations[t[0]][e.Id] // edge case: ica facility in h1 was only 1 sublocation, so we merge // these into a single array if ( req.gameVersion === "h1" && !t[0] && LOCATION === "LOCATION_PARENT_ICA_FACILITY" ) { theMissions = [ ...controller.missionsInLocations .LOCATION_ICA_FACILITY_ARRIVAL, ...controller.missionsInLocations .LOCATION_ICA_FACILITY_SHIP, ...controller.missionsInLocations.LOCATION_ICA_FACILITY, ] } if (theMissions !== undefined) { // eslint-disable-next-line no-extra-semi ;(theMissions as string[]) .filter( // removes snow festival on h1 (m) => m && !( req.gameVersion === "h1" && m === "c414a084-a7b9-43ce-b6ca-590620acd87e" ), ) .forEach((c) => { const mission = contractIdToHitObject( c, req.gameVersion, req.jwt.unique_name, ) obj[t[1]].push(mission) }) } } response.data.MissionData.SubLocationMissionsData.push(obj) } res.json(response) }, ) async function lookupContractPublicId( publicid: string, userId: string, gameVersion: GameVersion, ) { const publicIdRegex = /\d{12}/ while (publicid.includes("-")) { publicid = publicid.replace("-", "") } if (!publicIdRegex.test(publicid)) { return { PublicId: publicid, ErrorReason: "notfound", } } const contract = await controller.contractByPubId( publicid, userId, gameVersion, ) if (!contract) { return { PublicId: publicid, ErrorReason: "notfound", } } const location = getUnlockableById(contract.Metadata.Location, gameVersion) return { Contract: contract, Location: location, UserCentricContract: generateUserCentric(contract, userId, gameVersion), } } menuDataRouter.get( "/LookupContractPublicId", async (req: RequestWithJwt<{ publicid: string }>, res) => { if (!req.query.publicid || typeof req.query.publicid !== "string") { return res.status(400).send("no/invalid public id specified!") } res.json({ template: getVersionedConfig( "LookupContractByIdTemplate", req.gameVersion, false, ), data: await lookupContractPublicId( req.query.publicid, req.jwt.unique_name, req.gameVersion, ), }) }, ) menuDataRouter.get( "/HitsCategory", async ( req: RequestWithJwt<{ type: string; page?: number | string }>, res, ) => { const category = req.query.type const response: { template: unknown data?: HitsCategoryCategory } = { template: req.gameVersion === "h1" ? getConfig("LegacyHitsCategoryTemplate", false) : null, data: undefined, } let pageNumber = req.query.page || 0 if (typeof pageNumber === "string") { pageNumber = parseInt(pageNumber, 10) } pageNumber = pageNumber < 0 ? 0 : pageNumber response.data = await hitsCategoryService.paginateHitsCategory( category, pageNumber as number, req.gameVersion, req.jwt.unique_name, ) res.json(response) }, ) menuDataRouter.get( "/PlayNext", (req: RequestWithJwt<{ contractId: string }>, res) => { if (!req.query.contractId) { res.status(400).send("no contract id!") return } const cats = [] const currentIdIndex = orderedMissions.indexOf(req.query.contractId) if ( currentIdIndex !== -1 && currentIdIndex !== orderedMissions.length - 1 ) { const nextMissionId = orderedMissions[currentIdIndex + 1] const nextSeasonId = getSeasonId(currentIdIndex + 1) let shouldContinue = true // nextSeasonId > gameVersion's integer if (parseInt(nextSeasonId) > parseInt(req.gameVersion[1])) { shouldContinue = false } if (shouldContinue) { cats.push( createPlayNextTile( req.jwt.unique_name, nextMissionId, req.gameVersion, { CampaignName: `UI_SEASON_${nextSeasonId}`, }, ), ) } cats.push(createMainOpportunityTile(req.query.contractId)) } const pzIdIndex = orderedPZMissions.indexOf(req.query.contractId) if (pzIdIndex !== -1 && pzIdIndex !== orderedPZMissions.length - 1) { const nextMissionId = orderedPZMissions[pzIdIndex + 1] cats.push( createPlayNextTile( req.jwt.unique_name, nextMissionId, req.gameVersion, { CampaignName: "UI_CONTRACT_CAMPAIGN_WHITE_SPIDER_TITLE", ParentCampaignName: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE", }, ), ) } if (req.query.contractId === "f1ba328f-e3dd-4ef8-bb26-0363499fdd95") { const nextMissionId = "0b616e62-af0c-495b-82e3-b778e82b5912" cats.push( createPlayNextTile( req.jwt.unique_name, nextMissionId, req.gameVersion, { CampaignName: "UI_MENU_PAGE_SPECIAL_ASSIGNMENTS_TITLE", ParentCampaignName: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE", }, ), ) } if (sniperMissionIds.includes(req.query.contractId)) { cats.push(createMenuPageTile("sniper")) } const pluginData = controller.hooks.getNextCampaignMission.call( req.query.contractId, req.gameVersion, ) if (pluginData) { if (pluginData.overrideIndex !== undefined) { cats[pluginData.overrideIndex] = createPlayNextTile( req.jwt.unique_name, pluginData.nextContractId, req.gameVersion, pluginData.campaignDetails, ) } else { cats.push( createPlayNextTile( req.jwt.unique_name, pluginData.nextContractId, req.gameVersion, pluginData.campaignDetails, ), ) } } res.json({ template: getConfig("PlayNextTemplate", false), data: { Categories: cats, ProfileId: req.jwt.unique_name, }, }) }, ) menuDataRouter.get("/LeaderboardsView", (req, res) => { res.json({ template: getConfig("LeaderboardsViewTemplate", false), data: { LeaderboardUrl: "leaderboardentries", LeaderboardType: "singleplayer", }, }) }) const leaderboardEntries = async ( req: RequestWithJwt<{ contractid: string; difficultyLevel?: string }>, res: Response, ) => { let difficulty = "unset" const parsedDifficulty = parseInt(req.query?.difficultyLevel || "0") if (parsedDifficulty === gameDifficulty.casual) { difficulty = "casual" } if (parsedDifficulty === gameDifficulty.normal) { difficulty = "normal" } if (parsedDifficulty === gameDifficulty.master) { difficulty = "master" } const response = { template: getConfig("LeaderboardEntriesTemplate", false), data: { Entries: [] as ApiLeaderboardEntry[], Contract: controller.resolveContract(req.query.contractid), Page: 0, HasMore: false, LeaderboardType: "singleplayer", }, } type ApiLeaderboardEntry = { LeaderboardData: { Player: { displayName: string } } gameVersion: { id: number name: string } platformId: string platform: { id: number name: string } } const entries = ( await axios.post( `${getFlag("leaderboardsHost")}/leaderboards/entries/${ req.query.contractid }`, { gameVersion: req.gameVersion, difficulty, platform: req.jwt.platform, }, { headers: { "Peacock-Version": PEACOCKVERSTRING, }, }, ) ).data const ids: readonly string[] = entries.map((te) => fakePlayerRegistry.index( te.LeaderboardData.Player.displayName, te.platform.name, te.platformId, ), ) entries.forEach((entry, index) => { // @ts-expect-error Remapping on different types entry.LeaderboardData.Player = ids[index] return entry }) response.data.Entries = entries res.json(response) } menuDataRouter.get("/LeaderboardEntries", leaderboardEntries) menuDataRouter.get( "/DebriefingLeaderboards", async ( req: RequestWithJwt<{ contractid: string; difficulty?: string }>, res, ) => { const debriefingLeaderboardsTemplate = getConfig( "DebriefingLeaderboardsTemplate", false, ) const resJsonFunc = res.json res.json = function (input) { return resJsonFunc.call(this, { template: debriefingLeaderboardsTemplate, data: input.data, }) } await leaderboardEntries(req, res) }, ) menuDataRouter.get("/Contracts", contractsModeHome) preMenuDataRouter.get( "/contractcreation/planning", ( req: RequestWithJwt<{ contractCreationIdOverwrite: string }>, res, next, ) => { const createContractPlanningTemplate = getConfig( "CreateContractPlanningTemplate", false, ) req.url = "/Planning" req.query.contractid = req.query.contractCreationIdOverwrite req.query.resetescalation = "false" const originalJsonFunc = res.json res.json = function (originalData) { const d = originalData.data // create contract planning isn't supposed to have the following properties for (const key of [ "ElusiveContractState", "UserCentric", "UserContract", "UnlockedEntrances", "UnlockedAgencyPickups", "Objectives", "CharacterLoadoutData", "ChallengeData", "Currency", "PaymentDetails", "OpportunityData", "PlayerProfileXpData", ]) { d[key] = undefined } return originalJsonFunc.call(this, { template: createContractPlanningTemplate, data: d, }) } next("router") }, ) menuDataRouter.get("/contractsearchpage", (req: RequestWithJwt, res) => { const createContractTutorial = controller.resolveContract( contractCreationTutorialId, ) res.json({ template: getVersionedConfig( "ContractSearchPageTemplate", req.gameVersion, false, ), data: { CreateContractTutorial: generateUserCentric( createContractTutorial!, req.jwt.unique_name, req.gameVersion, ), LocationsData: createLocationsData(req.gameVersion, true), FilterData: getVersionedConfig( "FilterData", req.gameVersion, false, ), }, }) }) menuDataRouter.post( "/ContractSearch", jsonMiddleware(), async (req: RequestWithJwt<{ sorting?: unknown }, string[]>, res) => { const specialContracts: string[] = [] await controller.hooks.getSearchResults.callAsync( req.body, specialContracts, ) let searchResult: ContractSearchResult if (specialContracts.length > 0) { // Handled by a plugin const contracts: { UserCentricContract: UserCentricContract }[] = [] for (const contract of specialContracts) { const userCentric = generateUserCentric( controller.resolveContract(contract), req.jwt.unique_name, req.gameVersion, ) if (!userCentric) { log( LogLevel.ERROR, "UC is null! (contract not registered?)", ) continue } contracts.push({ UserCentricContract: userCentric, }) } searchResult = { Data: { Contracts: contracts, TotalCount: contracts.length, Page: 0, ErrorReason: "", HasPrevious: false, HasMore: false, }, } } else { // No plugins handle this. Getting search results from official searchResult = await officialSearchContract( req.jwt.unique_name, req.gameVersion, req.body, 0, ) } res.json({ template: getVersionedConfig( "ContractSearchResponseTemplate", req.gameVersion, false, ), data: searchResult, }) }, ) menuDataRouter.post( "/ContractSearchPaginate", jsonMiddleware(), async (req: RequestWithJwt<{ page: number }, string[]>, res) => { res.json({ template: getConfig("ContractSearchPaginateTemplate", false), data: await officialSearchContract( req.jwt.unique_name, req.gameVersion, req.body, req.query.page, ), }) }, ) menuDataRouter.get( "/DebriefingChallenges", (req: RequestWithJwt<{ contractId: string }>, res) => { res.json({ template: getConfig("DebriefingChallengesTemplate", false), data: { ChallengeData: { Children: controller.challengeService.getChallengeTreeForContract( req.query.contractId, req.gameVersion, req.jwt.unique_name, ), }, }, }) }, ) menuDataRouter.get("/contractcreation/create", (req: RequestWithJwt, res) => { let cUuid = randomUUID() const createContractReturnTemplate = getConfig( "CreateContractReturnTemplate", false, ) // if for some reason the id is already in use, generate a new one // the math says this is like a one in a billion chance though, I think while (controller.resolveContract(cUuid)) { cUuid = randomUUID() } const sesh = getSession(req.jwt.unique_name) const one = "1" const two = `${random.int(10, 99)}` const three = `${random.int(1_000_000, 9_999_999)}` const four = `${random.int(10, 99)}` const contractId = [one, two, three, four].join("-") const joined = [one, two, three, four].join("") // See my comment in contractRouting.ts about the Math.ceil call const timeLimit = Math.ceil( (sesh.timerEnd as number) - (sesh.timerStart as number), ) const timeSeconds = timeLimit % 60 const timeMinutes = Math.trunc(timeLimit / 60) % 60 const timeHours = Math.trunc(timeLimit / 3600) const timeLimitStr = `${ timeHours ? `${timeHours}:` : "" }${`0${timeMinutes}`.slice(-2)}:${`0${timeSeconds}`.slice(-2)}` res.json({ template: createContractReturnTemplate, data: { Contract: { Title: { $loc: { key: "UI_CONTRACTS_UGC_TITLE", data: [contractId], }, }, Description: "UI_CONTRACTS_UGC_DESCRIPTION", Targets: Array.from(sesh.kills) .filter((kill) => sesh.markedTargets.has(kill._RepositoryId), ) .map((km) => { return { RepositoryId: km._RepositoryId, Selected: true, Weapon: { RepositoryId: km.KillItemRepositoryId, KillMethodBroad: km.KillMethodBroad, KillMethodStrict: km.KillMethodStrict, RequiredKillMethodType: 3, }, Outfit: { RepositoryId: km.OutfitRepoId, Required: true, IsHitmanSuit: isSuit(km.OutfitRepoId), }, } }), ContractConditions: complications(timeLimitStr), PublishingDisabled: sesh.contractId === contractCreationTutorialId, Creator: req.jwt.unique_name, ContractId: cUuid, ContractPublicId: joined, }, }, }) }) const createLoadSaveMiddleware = (menuTemplate: string) => ( req: RequestWithJwt< { sessionIds?: string }, string[] >, res: Response, ) => { const template = getVersionedConfig( menuTemplate, req.gameVersion, false, ) const doneContracts: string[] = [] const response = { template, data: { Contracts: [] as UserCentricContract[], PaymentEligiblity: {}, }, } for (const e of req.body) { if (e && !doneContracts.includes(e)) { doneContracts.push(e) const contract = controller.resolveContract(e) if (!contract) { log(LogLevel.WARN, `Unknown contract in L/S: ${e}`) continue } response.data.Contracts.push( generateUserCentric( contract, req.jwt.unique_name, req.gameVersion, )!, ) } } if (req.gameVersion === "h1") { for (const e of req.body) { if (e) { response.data.PaymentEligiblity[e] = false } } } else { for (const sessionId of req.query.sessionIds?.split(",") || []) { response.data.PaymentEligiblity[sessionId] = false } } res.json(response) } menuDataRouter.post( "/Load", jsonMiddleware(), createLoadSaveMiddleware("LoadMenuTemplate"), ) menuDataRouter.post( "/Save", jsonMiddleware(), createLoadSaveMiddleware("SaveMenuTemplate"), ) menuDataRouter.get("/PlayerProfile", (req: RequestWithJwt, res) => { const playerProfilePage = getConfig( "PlayerProfilePage", true, ) const locationData = getVersionedConfig( "LocationsData", req.gameVersion, false, ) playerProfilePage.data.SubLocationData = [] for (const subLocationKey in locationData.children) { // Ewww... if ( subLocationKey === "LOCATION_ICA_FACILITY_ARRIVAL" || subLocationKey === "LOCATION_HOKKAIDO_SHIM_MAMUSHI" || subLocationKey.includes("SNUG_") ) { continue } const subLocation = locationData.children[subLocationKey] const parentLocation = locationData.parents[subLocation.Properties.ParentLocation] const completionData = generateCompletionData( subLocation.Id, req.jwt.unique_name, req.gameVersion, ) // TODO: Make getDestinationCompletion do something like this. const challenges = controller.challengeService.getChallengesForLocation( subLocation.Id, req.gameVersion, ) const challengeCategoryCompletion: ChallengeCategoryCompletion[] = [] for (const challengeGroup in challenges) { const challengeCompletion = controller.challengeService.countTotalNCompletedChallenges( { challengeGroup: challenges[challengeGroup], }, req.jwt.unique_name, req.gameVersion, ) challengeCategoryCompletion.push({ Name: challenges[challengeGroup][0].CategoryName, ...challengeCompletion, }) } const destinationCompletion = getDestinationCompletion( parentLocation, subLocation, req, ) playerProfilePage.data.SubLocationData.push({ ParentLocation: parentLocation, Location: subLocation, CompletionData: completionData, ChallengeCategoryCompletion: challengeCategoryCompletion, ChallengeCompletion: destinationCompletion.ChallengeCompletion, OpportunityStatistics: destinationCompletion.OpportunityStatistics, LocationCompletionPercent: destinationCompletion.LocationCompletionPercent, }) } const userProfile = getUserData(req.jwt.unique_name, req.gameVersion) playerProfilePage.data.PlayerProfileXp.Total = userProfile.Extensions.progression.PlayerProfileXP.Total playerProfilePage.data.PlayerProfileXp.Level = userProfile.Extensions.progression.PlayerProfileXP.ProfileLevel const subLocationMap = new Map( userProfile.Extensions.progression.PlayerProfileXP.Sublocations.map( (obj) => [obj.Location, obj], ), ) playerProfilePage.data.PlayerProfileXp.Seasons.forEach((e) => e.Locations.forEach((f) => { const subLocationData = subLocationMap.get(f.LocationId) f.Xp = subLocationData?.Xp || 0 f.ActionXp = subLocationData?.ActionXp || 0 if (f.LocationProgression && !isSniperLocation(f.LocationId)) { // We typecast below as it could be an object for subpackages. // Checks before this ensure it isn't, but TS doesn't realise this. f.LocationProgression.Level = ( userProfile.Extensions.progression.Locations[ f.LocationId ] as ProgressionData ).Level || 1 } }), ) res.json(playerProfilePage) }) menuDataRouter.get( // who at IOI decided this was a good route name???! "/LookupContractDialogAddOrDeleteFromPlaylist", withLookupDialog, ) menuDataRouter.get( // this one is sane Kappa "/contractplaylist/addordelete/:contractId", directRoute, ) menuDataRouter.post( "/contractplaylist/deletemultiple", jsonMiddleware(), deleteMultiple, ) menuDataRouter.get("/GetPlayerProfileXpData", (req: RequestWithJwt, res) => { const userData = getUserData(req.jwt.unique_name, req.gameVersion) res.json({ template: null, data: { PlayerProfileXpData: { XP: userData.Extensions.progression.PlayerProfileXP.Total, Level: userData.Extensions.progression.PlayerProfileXP .ProfileLevel, MaxLevel: getMaxProfileLevel(req.gameVersion), }, }, }) }) menuDataRouter.get( "/GetMasteryCompletionDataForLocation", (req: RequestWithJwt, res) => { res.json( generateCompletionData( req.query.locationId, req.jwt.unique_name, req.gameVersion, ), ) }, ) menuDataRouter.get( "/MasteryUnlockable", (req: RequestWithJwt, res) => { let masteryUnlockTemplate = getConfig( "MasteryUnlockablesTemplate", false, ) const parentLocation = (() => { switch (req.query.unlockableId?.split("_").slice(0, 3).join("_")) { case "FIREARMS_SC_HERO": return "LOCATION_PARENT_AUSTRIA" case "FIREARMS_SC_SEAGULL": return "LOCATION_PARENT_SALTY" case "FIREARMS_SC_FALCON": return "LOCATION_PARENT_CAGED" default: assert.fail("fell through switch (bad query?)") } })() if (req.gameVersion === "scpc") { masteryUnlockTemplate = JSON.parse( JSON.stringify(masteryUnlockTemplate).replace( /UI_MENU_PAGE_MASTERY_LEVEL_SHORT+/g, "UI_MENU_PAGE_MASTERY_LEVEL", ), ) // Do we still need to do this? - AF // sniperLoadout = JSON.parse( // JSON.stringify(sniperLoadout).replace(/hawk\/+/g, ""), // ) } res.json({ template: masteryUnlockTemplate, data: { ...controller.masteryService.getMasteryDataForSubPackage( parentLocation, req.query.unlockableId, req.gameVersion, req.jwt.unique_name, ), }, }) }, ) menuDataRouter.get( "/MasteryDataForLocation", (req: RequestWithJwt<{ locationId: string }>, res) => { res.json( controller.masteryService.getMasteryDataForLocation( req.query.locationId, req.gameVersion, req.jwt.unique_name, ), ) }, ) menuDataRouter.get( "/GetMasteryCompletionDataForUnlockable", (req: RequestWithJwt<{ unlockableId: string }>, res) => { // We make this lookup table to quickly get it, there's no other quick way for it. const unlockToLoc = { FIREARMS_SC_HERO_SNIPER_HM: "LOCATION_PARENT_AUSTRIA", FIREARMS_SC_HERO_SNIPER_KNIGHT: "LOCATION_PARENT_AUSTRIA", FIREARMS_SC_HERO_SNIPER_STONE: "LOCATION_PARENT_AUSTRIA", FIREARMS_SC_SEAGULL_HM: "LOCATION_PARENT_SALTY", FIREARMS_SC_SEAGULL_KNIGHT: "LOCATION_PARENT_SALTY", FIREARMS_SC_SEAGULL_STONE: "LOCATION_PARENT_SALTY", FIREARMS_SC_FALCON_HM: "LOCATION_PARENT_CAGED", FIREARMS_SC_FALCON_KNIGHT: "LOCATION_PARENT_CAGED", FIREARMS_SC_FALCON_STONE: "LOCATION_PARENT_CAGED", } res.json({ template: null, data: { CompletionData: controller.masteryService.getLocationCompletion( unlockToLoc[req.query.unlockableId], unlockToLoc[req.query.unlockableId], req.gameVersion, req.jwt.unique_name, "sniper", req.query.unlockableId, ), }, }) }, ) export { menuDataRouter }