/*
* 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 }