1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-16 11:03:30 +01:00

Separate routing logic and business logic (#352)

This commit is contained in:
Reece Dunham 2024-01-13 17:46:42 -05:00 committed by GitHub
parent abd9ada63b
commit 0e66d69505
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2054 additions and 1742 deletions

View File

@ -2,6 +2,7 @@
<dictionary name="reece">
<words>
<w>ascensionist</w>
<w>atlantide</w>
<w>bosco</w>
<w>calluna</w>
<w>cereus</w>

View File

@ -1,43 +0,0 @@
/*
* 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 { Router } from "express"
import { json as jsonMiddleware } from "body-parser"
const legacyEventRouter = Router()
legacyEventRouter.post(
"/SaveAndSynchronizeEvents3",
jsonMiddleware({ limit: "10Mb" }),
(req, res, next) => {
// call /SaveAndSynchronizeEvents4 but add/remove dummy pushMessages
req.url = "/SaveAndSynchronizeEvents4"
req.body.lastPushDt = "0"
const originalJsonFunc = res.json
res.json = function (originalData) {
delete originalData.PushMessages
return originalJsonFunc.call(this, originalData)
}
next()
},
)
export { legacyEventRouter }

View File

@ -19,227 +19,13 @@
import { Router } from "express"
import { RequestWithJwt } from "../types/types"
import { getConfig } from "../configSwizzleManager"
import { getDefaultSuitFor, uuidRegex } from "../utils"
import { json as jsonMiddleware } from "body-parser"
import { controller } from "../controller"
import {
generateUserCentric,
getParentLocationByName,
getSubLocationByName,
} from "../contracts/dataGen"
import { getUserData } from "../databaseHandler"
import { log, LogLevel } from "../loggingInterop"
import { createInventory, getUnlockableById } from "../inventory"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
import { StashpointQueryH2016, StashpointSlotName } from "../types/gameSchemas"
import { getParentLocationByName } from "../contracts/dataGen"
const legacyMenuDataRouter = Router()
legacyMenuDataRouter.get(
"/stashpoint",
(req: RequestWithJwt<StashpointQueryH2016>, res) => {
if (!uuidRegex.test(req.query.contractid)) {
res.status(400).send("contract id was not a uuid")
return
}
if (typeof req.query.slotname !== "string") {
res.status(400).send("invalid slot data")
return
}
const contractData = controller.resolveContract(req.query.contractid)
if (!contractData) {
res.status(404).send("contract not found")
return
}
const loadoutSlots: StashpointSlotName[] = [
"carriedweapon",
"carrieditem",
"concealedweapon",
"disguise",
"gear",
"gear",
"stashpoint",
]
if (loadoutSlots.includes(req.query.slotname.slice(0, -1))) {
req.query.slotid = req.query.slotname.slice(0, -1)
} else {
log(
LogLevel.ERROR,
`Unknown slotname in legacy stashpoint: ${req.query.slotname}`,
)
return
}
const userProfile = getUserData(req.jwt.unique_name, req.gameVersion)
const sublocation = getSubLocationByName(
contractData.Metadata.Location,
req.gameVersion,
)
const inventory = createInventory(
req.jwt.unique_name,
req.gameVersion,
sublocation,
)
const userCentricContract = generateUserCentric(
contractData,
req.jwt.unique_name,
"h1",
)
const defaultLoadout = {
2: "FIREARMS_HERO_PISTOL_TACTICAL_001_SU_SKIN01",
3: getDefaultSuitFor(sublocation),
4: "TOKEN_FIBERWIRE",
5: "PROP_TOOL_COIN",
}
const getLoadoutItem = (id: number) => {
if (getFlag("loadoutSaving") === "LEGACY") {
const dl = userProfile.Extensions.defaultloadout
if (!dl) {
return defaultLoadout[id]
}
const forLocation = (userProfile.Extensions.defaultloadout ||
{})[sublocation?.Properties?.ParentLocation]
if (!forLocation) {
return defaultLoadout[id]
}
return forLocation[id]
} else {
let dl = loadouts.getLoadoutFor("h1")
if (!dl) {
dl = loadouts.createDefault("h1")
}
const forLocation =
dl.data[sublocation?.Properties?.ParentLocation]
if (!forLocation) {
return defaultLoadout[id]
}
return forLocation[id]
}
}
res.json({
template: getConfig("LegacyStashpointTemplate", false),
data: {
ContractId: req.query.contractid,
// the game actually only needs the loadoutdata from the requested slotid, but this is what IOI servers do
LoadoutData: [...loadoutSlots.entries()].map(
([slotid, slotname]) => ({
SlotName: slotname,
SlotId: slotid.toString(),
Items: inventory
.filter((item) => {
return (
item.Unlockable.Properties.LoadoutSlot && // only display items
(item.Unlockable.Properties.LoadoutSlot ===
slotname || // display items for requested slot
(slotname === "stashpoint" && // else: if stashpoint
item.Unlockable.Properties
.LoadoutSlot !== "disguise")) && // => display all non-disguise items
(req.query.allowlargeitems === "true" ||
item.Unlockable.Properties.ItemSize === // regular gear slot or hidden stash => small item
"ITEMSIZE_SMALL" ||
(!item.Unlockable.Properties.ItemSize &&
item.Unlockable.Properties
.LoadoutSlot !== // use old logic if itemsize is not set
"carriedweapon")) &&
item.Unlockable.Type !==
"challengemultipler" &&
!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: slotid.toString(),
SlotName: slotname,
})),
Page: 0,
Recommended: getLoadoutItem(slotid)
? {
item: getUnlockableById(
getLoadoutItem(slotid),
req.gameVersion,
),
type: loadoutSlots[slotid],
owned: true,
}
: null,
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData:
slotid === 6
? {
stashpoint: req.query.stashpoint,
AllowLargeItems:
req.query.allowlargeitems ||
!req.query.stashpoint,
}
: {},
}),
),
Contract: userCentricContract.Contract,
ShowSlotName: req.query.slotname,
UserCentric: userCentricContract,
},
})
},
)
legacyMenuDataRouter.get("/Safehouse", (req: RequestWithJwt, res, next) => {
const template = getConfig("LegacySafehouseTemplate", false)
// call /SafehouseCategory but rewrite the result a bit
req.url = `/SafehouseCategory?page=0&type=${req.query.type}&subtype=`
const originalJsonFunc = res.json
res.json = function json(originalData) {
return originalJsonFunc.call(this, {
template,
data: {
SafehouseData: originalData.data,
},
})
}
next()
})
legacyMenuDataRouter.get(
"/debriefingchallenges",
jsonMiddleware(),
(
req: RequestWithJwt<{ contractSessionId: string; contractId: string }>,
res,
@ -269,7 +55,6 @@ legacyMenuDataRouter.get(
legacyMenuDataRouter.get(
"/MasteryLocation",
jsonMiddleware(),
(req: RequestWithJwt<{ locationId: string; difficulty: string }>, res) => {
const masteryData =
controller.masteryService.getMasteryDataForDestination(

View File

@ -16,13 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import serveStatic from "serve-static"
import { Router } from "express"
import { join } from "path"
import md5File from "md5-file"
import { readFile } from "atomically"
import { imageFetchingMiddleware } from "../menus/imageHandler"
import { MenuSystemDatabase } from "../menus/menuSystem"
const legacyMenuSystemRouter = Router()
@ -46,12 +43,4 @@ legacyMenuSystemRouter.get(
},
)
legacyMenuSystemRouter.use(MenuSystemDatabase.configMiddleware)
legacyMenuSystemRouter.use(
"/images/",
serveStatic("images", { fallthrough: true }),
imageFetchingMiddleware,
)
export { legacyMenuSystemRouter }

View File

@ -26,10 +26,8 @@ import { getConfig } from "../configSwizzleManager"
import { Router } from "express"
import { controller } from "../controller"
import { getPlatformEntitlements } from "../platformEntitlements"
import { json as jsonMiddleware } from "body-parser"
import { uuidRegex } from "../utils"
import { menuSystemDatabase } from "../menus/menuSystem"
import { compileRuntimeChallenge } from "../candle/challengeHelpers"
import { LegacyGetProgressionBody } from "../types/gameSchemas"
@ -37,23 +35,6 @@ const legacyProfileRouter = Router()
// /authentication/api/userchannel/
legacyProfileRouter.post(
"/ProfileService/GetPlatformEntitlements",
jsonMiddleware(),
getPlatformEntitlements,
)
legacyProfileRouter.post(
"/AuthenticationService/GetBlobOfflineCacheDatabaseDiff",
(req: RequestWithJwt, res) => {
const configs: string[] = []
menuSystemDatabase.hooks.getDatabaseDiff.call(configs, req.gameVersion)
res.json(configs)
},
)
legacyProfileRouter.post(
"/ChallengesService/GetActiveChallenges",
jsonMiddleware(),

View File

@ -28,6 +28,7 @@ import { SavedChallengeGroup } from "../types/challenges"
import { controller } from "../controller"
import { gameDifficulty, isSniperLocation } from "../utils"
// TODO: unused?
export function compileScoringChallenge(
challenge: RegistryChallenge,
): CompiledChallengeRewardData {
@ -68,7 +69,7 @@ export function compileRuntimeChallenge(
}
export enum ChallengeFilterType {
// Note that this option will include global elusives and escalations challenges.
/** Note that this option will include global elusive and escalations challenges. */
None = "None",
Contract = "Contract",
/** Only used for the CAREER -> CHALLENGES page */

View File

@ -150,6 +150,8 @@ export class MasteryService {
)[0]
}
// TODO: what do we want to do with this? We should prob remove the template part
// to make this like the other routes, and more testable.
getMasteryDataForLocation(
locationId: string,
gameVersion: GameVersion,

View File

@ -280,7 +280,7 @@ export function getVersionedConfig<T = unknown>(
if (
// is this scpc, do we have a scpc config?
gameVersion === "scpc" &&
Object.prototype.hasOwnProperty.call(configs, `Scpc${config}`)
Object.hasOwn(configs, `Scpc${config}`)
) {
h1Prefix = "Scpc"
} else {
@ -291,10 +291,7 @@ export function getVersionedConfig<T = unknown>(
}
// if this is H2, but we don't have a h2 specific config, fall back to h3
if (
gameVersion === "h2" &&
!Object.prototype.hasOwnProperty.call(configs, `H2${config}`)
) {
if (gameVersion === "h2" && !Object.hasOwn(configs, `H2${config}`)) {
return getConfig(config, clone)
}

View File

@ -140,12 +140,7 @@ contractRoutingRouter.post(
contractData.Data.GameChangerReferences || []
for (const gameChangerId of contractData.Data.GameChangers) {
if (
!Object.prototype.hasOwnProperty.call(
gameChangerData,
gameChangerId,
)
) {
if (!gameChangerData[gameChangerId]) {
log(
LogLevel.ERROR,
`GetForPlay has detected a missing GameChanger: ${gameChangerId}! This is a bug.`,
@ -287,12 +282,7 @@ contractRoutingRouter.post(
req.body.creationData.ContractConditionIds.forEach(
(contractConditionId) => {
if (
Object.prototype.hasOwnProperty.call(
gameChangerData,
contractConditionId,
)
) {
if (gameChangerData[contractConditionId]) {
gamechangers.push(contractConditionId)
} else if (
contractConditionId ===

View File

@ -0,0 +1,116 @@
/*
* 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 { gameDifficulty, PEACOCKVERSTRING } from "../utils"
import { controller } from "../controller"
import axios from "axios"
import { getFlag } from "../flags"
import { fakePlayerRegistry } from "../profileHandler"
import { GameVersion, JwtData, MissionManifest } from "../types/types"
type ApiLeaderboardEntry = {
LeaderboardData: {
Player: {
displayName: string
}
}
gameVersion: {
id: number
name: string
}
platformId: string
platform: {
id: number
name: string
}
}
type GameFacingLeaderboardData = {
Entries: ApiLeaderboardEntry[]
Contract: MissionManifest
Page: number
HasMore: boolean
LeaderboardType: string
}
export async function getLeaderboardEntries(
contractId: string,
platform: JwtData["platform"],
gameVersion: GameVersion,
difficultyLevel?: string,
): Promise<GameFacingLeaderboardData> {
let difficulty = "unset"
const parsedDifficulty = parseInt(difficultyLevel || "0")
if (parsedDifficulty === gameDifficulty.casual) {
difficulty = "casual"
}
if (parsedDifficulty === gameDifficulty.normal) {
difficulty = "normal"
}
if (parsedDifficulty === gameDifficulty.master) {
difficulty = "master"
}
const response: GameFacingLeaderboardData = {
Entries: [],
Contract: controller.resolveContract(contractId),
Page: 0,
HasMore: false,
LeaderboardType: "singleplayer",
}
const host = getFlag("leaderboardsHost") as string
const entries = (
await axios.post<ApiLeaderboardEntry[]>(
`${host}/leaderboards/entries/${contractId}`,
{
gameVersion,
difficulty,
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.Entries = entries
return response
}

View File

@ -188,12 +188,7 @@ function createPeacockRequire(pluginName: string): NodeRequire {
* @param specifier The requested module.
*/
const peacockRequire: NodeRequire = (specifier: string) => {
if (
Object.prototype.hasOwnProperty.call(
generatedPeacockRequireTable,
specifier,
)
) {
if (generatedPeacockRequireTable[specifier]) {
return generatedPeacockRequireTable[specifier]
}
@ -312,7 +307,10 @@ export class Controller {
newEvent: SyncHook<
[
/** event */ ClientToServerEvent,
/** request */ RequestWithJwt,
/** details */ {
gameVersion: GameVersion
userId: string
},
/** session */ ContractSession,
]
>

View File

@ -150,12 +150,7 @@ export function scenePathToRpAsset(
)
for (const brick of bricks) {
if (
Object.prototype.hasOwnProperty.call(
brickAssetsMap,
brick.toLowerCase(),
)
) {
if (brickAssetsMap[brick.toLowerCase()]) {
return brickAssetsMap[brick.toLowerCase()]!
}
}

View File

@ -31,7 +31,7 @@ import {
Seconds,
ServerToClientEvent,
} from "./types/types"
import { contractTypes, extractToken, gameDifficulty, ServerVer } from "./utils"
import { contractTypes, gameDifficulty, ServerVer } from "./utils"
import { json as jsonMiddleware } from "body-parser"
import { log, LogLevel } from "./loggingInterop"
import { getUserData, writeUserData } from "./databaseHandler"
@ -372,18 +372,74 @@ export function newSession(
controller.challengeService.startContract(contractSessions.get(sessionId)!)
}
export type SSE3Response = {
SavedTokens: string[]
NewEvents: ServerToClientEvent[]
NextPoll: number
}
export type SSE4Response = SSE3Response & {
PushMessages: string[]
}
export function saveAndSyncEvents(
version: 3 | 4,
userId: string,
gameVersion: GameVersion,
lastEventTicks: number | string,
values: ClientToServerEvent[],
lastPushDt?: number | string,
): SSE3Response | SSE4Response {
const savedTokens = values.length
? saveEvents(userId, values, gameVersion)
: null
let userQueue: S2CEventWithTimestamp[] | undefined
let newEvents: ServerToClientEvent[] | null = null
// events: (server -> client)
if ((userQueue = eventQueue.get(userId))) {
userQueue = userQueue.filter((item) => item.time > lastEventTicks)
eventQueue.set(userId, userQueue)
newEvents = Array.from(userQueue, (item) => item.event)
}
// push messages: (server -> client)
let userPushQueue: PushMessage[] | undefined
let pushMessages: string[] | undefined
if ((userPushQueue = pushMessageQueue.get(userId))) {
userPushQueue = userPushQueue.filter((item) => item.time > lastPushDt)
pushMessageQueue.set(userId, userPushQueue)
pushMessages = Array.from(userPushQueue, (item) => item.message)
}
const sse3Response: SSE3Response = {
SavedTokens: savedTokens,
NewEvents: newEvents || null,
NextPoll: 10.0,
}
return version === 3
? sse3Response
: {
...sse3Response,
PushMessages: pushMessages || null,
}
}
eventRouter.post(
"/SaveAndSynchronizeEvents4",
extractToken,
"/SaveAndSynchronizeEvents3",
jsonMiddleware({ limit: "10Mb" }),
(
req: RequestWithJwt<
unknown,
{
lastPushDt: number | string
lastEventTicks: number | string
userId?: string
values?: []
userId: string
values: ClientToServerEvent[]
}
>,
res,
@ -398,48 +454,58 @@ eventRouter.post(
return
}
const savedTokens = req.body.values.length
? saveEvents(req.body.userId, req.body.values, req)
: null
res.json(
saveAndSyncEvents(
3,
req.jwt.unique_name,
req.gameVersion,
req.body.lastEventTicks,
req.body.values,
),
)
},
)
let userQueue: S2CEventWithTimestamp[] | undefined
let newEvents: ServerToClientEvent[] | null = null
// events: (server -> client)
if ((userQueue = eventQueue.get(req.jwt.unique_name))) {
userQueue = userQueue.filter(
(item) => item.time > req.body.lastEventTicks,
)
eventQueue.set(req.jwt.unique_name, userQueue)
newEvents = Array.from(userQueue, (item) => item.event)
eventRouter.post(
"/SaveAndSynchronizeEvents4",
jsonMiddleware({ limit: "10Mb" }),
(
req: RequestWithJwt<
unknown,
{
lastPushDt: number | string
lastEventTicks: number | string
userId: string
values: ClientToServerEvent[]
}
>,
res,
) => {
if (req.body.userId !== req.jwt.unique_name) {
res.status(403).send() // Trying to save events for other user
return
}
// push messages: (server -> client)
let userPushQueue: PushMessage[] | undefined
let pushMessages: string[] | null = null
if ((userPushQueue = pushMessageQueue.get(req.jwt.unique_name))) {
userPushQueue = userPushQueue.filter(
(item) => item.time > req.body.lastPushDt,
)
pushMessageQueue.set(req.jwt.unique_name, userPushQueue)
pushMessages = Array.from(userPushQueue, (item) => item.message)
if (!Array.isArray(req.body.values)) {
res.status(400).end() // malformed request
return
}
res.json({
SavedTokens: savedTokens,
NewEvents: newEvents || null,
NextPoll: 10.0,
PushMessages: pushMessages || null,
})
res.json(
saveAndSyncEvents(
4,
req.jwt.unique_name,
req.gameVersion,
req.body.lastEventTicks,
req.body.values,
req.body.lastPushDt,
),
)
},
)
eventRouter.post(
"/SaveEvents2",
extractToken,
jsonMiddleware({ limit: "10Mb" }),
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.userId) {
@ -447,7 +513,7 @@ eventRouter.post(
return
}
res.json(saveEvents(req.body.userId, req.body.values, req))
res.json(saveEvents(req.body.userId, req.body.values, req.gameVersion))
},
)
@ -574,11 +640,11 @@ function contractFailed(
function saveEvents(
userId: string,
events: ClientToServerEvent[],
req: RequestWithJwt<unknown, unknown>,
gameVersion: GameVersion,
): string[] {
const response: string[] = []
const processed: string[] = []
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const userData = getUserData(userId, gameVersion)
events.forEach((event) => {
let session = contractSessions.get(event.ContractSessionId)
@ -591,9 +657,9 @@ function saveEvents(
newSession(
event.ContractSessionId,
event.ContractId,
req.jwt.unique_name,
userId,
gameDifficulty.normal,
req.gameVersion,
gameVersion,
false,
)
@ -620,8 +686,16 @@ function saveEvents(
const contract = controller.resolveContract(session.contractId)
const contractType = contract?.Metadata?.Type?.toLowerCase()
// @ts-expect-error Issue with request type mismatch.
controller.hooks.newEvent.call(event, req, session)
controller.hooks.newEvent.call(
event,
// to avoid breakage, we pass details as an object instead of the request
// since we no longer have access to that
{
gameVersion,
userId,
},
session,
)
for (const objectiveId of session.objectiveStates.keys()) {
try {
@ -795,7 +869,7 @@ function saveEvents(
)
break
case "BodyFound":
if (req.gameVersion === "h1") {
if (gameVersion === "h1") {
session.legacyHasBodyBeenFound = true
}
@ -812,8 +886,8 @@ function saveEvents(
session.disguisesUsed.add(disguise)
liveSplitManager.startMission(
session.contractId,
req.gameVersion,
req.jwt.unique_name,
gameVersion,
userId,
)
break
}
@ -924,7 +998,7 @@ function saveEvents(
opportunities[val.RepositoryId] = true
}
writeUserData(req.jwt.unique_name, req.gameVersion)
writeUserData(userId, gameVersion)
break
}
case "AreaDiscovered":

View File

@ -42,7 +42,6 @@ import * as smfSupport from "./smfSupport"
import * as utils from "./utils"
import * as webFeatures from "./webFeatures"
import * as legacyContractHandler from "./2016/legacyContractHandler"
import * as legacyEventRouter from "./2016/legacyEventRouter"
import * as legacyMenuData from "./2016/legacyMenuData"
import * as legacyMenuSystem from "./2016/legacyMenuSystem"
import * as legacyProfileRouter from "./2016/legacyProfileRouter"
@ -56,6 +55,7 @@ import * as dataGen from "./contracts/dataGen"
import * as elusiveTargetArcades from "./contracts/elusiveTargetArcades"
import * as elusiveTargets from "./contracts/elusiveTargets"
import * as hitsCategoryService from "./contracts/hitsCategoryService"
import * as leaderboards from "./contracts/leaderboards"
import * as missionsInLocation from "./contracts/missionsInLocation"
import * as reportRouting from "./contracts/reportRouting"
import * as client from "./discord/client"
@ -65,24 +65,18 @@ import * as liveSplitManager from "./livesplit/liveSplitManager"
import * as campaigns from "./menus/campaigns"
import * as destinations from "./menus/destinations"
import * as favoriteContracts from "./menus/favoriteContracts"
import * as hub from "./menus/hub"
import * as imageHandler from "./menus/imageHandler"
import * as menuSystem from "./menus/menuSystem"
import * as planning from "./menus/planning"
import * as playnext from "./menus/playnext"
import * as sniper from "./menus/sniper"
import * as stashpoints from "./menus/stashpoints"
import * as multiplayerMenuData from "./multiplayer/multiplayerMenuData"
import * as multiplayerService from "./multiplayer/multiplayerService"
import * as multiplayerUtils from "./multiplayer/multiplayerUtils"
import * as contextListeners from "./statemachines/contextListeners"
import * as contractCreation from "./statemachines/contractCreation"
import * as challenges from "./types/challenges"
import * as events from "./types/events"
import * as gameSchemas from "./types/gameSchemas"
import * as livesplit from "./types/livesplit"
import * as mastery from "./types/mastery"
import * as score from "./types/score"
import * as scoring from "./types/scoring"
import * as types from "./types/types"
import * as escalationService from "./contracts/escalations/escalationService"
export default {
@ -142,10 +136,6 @@ export default {
__esModule: true,
...legacyContractHandler,
},
"@peacockproject/core/2016/legacyEventRouter": {
__esModule: true,
...legacyEventRouter,
},
"@peacockproject/core/2016/legacyMenuData": {
__esModule: true,
...legacyMenuData,
@ -195,6 +185,10 @@ export default {
__esModule: true,
...hitsCategoryService,
},
"@peacockproject/core/contracts/leaderboards": {
__esModule: true,
...leaderboards,
},
"@peacockproject/core/contracts/missionsInLocation": {
__esModule: true,
...missionsInLocation,
@ -222,6 +216,7 @@ export default {
__esModule: true,
...favoriteContracts,
},
"@peacockproject/core/menus/hub": { __esModule: true, ...hub },
"@peacockproject/core/menus/imageHandler": {
__esModule: true,
...imageHandler,
@ -233,6 +228,10 @@ export default {
"@peacockproject/core/menus/planning": { __esModule: true, ...planning },
"@peacockproject/core/menus/playnext": { __esModule: true, ...playnext },
"@peacockproject/core/menus/sniper": { __esModule: true, ...sniper },
"@peacockproject/core/menus/stashpoints": {
__esModule: true,
...stashpoints,
},
"@peacockproject/core/multiplayer/multiplayerMenuData": {
__esModule: true,
...multiplayerMenuData,
@ -253,20 +252,6 @@ export default {
__esModule: true,
...contractCreation,
},
"@peacockproject/core/types/challenges": {
__esModule: true,
...challenges,
},
"@peacockproject/core/types/events": { __esModule: true, ...events },
"@peacockproject/core/types/gameSchemas": {
__esModule: true,
...gameSchemas,
},
"@peacockproject/core/types/livesplit": { __esModule: true, ...livesplit },
"@peacockproject/core/types/mastery": { __esModule: true, ...mastery },
"@peacockproject/core/types/score": { __esModule: true, ...score },
"@peacockproject/core/types/scoring": { __esModule: true, ...scoring },
"@peacockproject/core/types/types": { __esModule: true, ...types },
"@peacockproject/core/contracts/escalations/escalationService": {
__esModule: true,
...escalationService,

View File

@ -59,9 +59,8 @@ import {
import { eventRouter } from "./eventHandler"
import { contractRoutingRouter } from "./contracts/contractRouting"
import { profileRouter } from "./profileHandler"
import { menuDataRouter, preMenuDataRouter } from "./menuData"
import { menuDataRouter } from "./menuData"
import { menuSystemPreRouter, menuSystemRouter } from "./menus/menuSystem"
import { legacyEventRouter } from "./2016/legacyEventRouter"
import { legacyMenuSystemRouter } from "./2016/legacyMenuSystem"
import { _theLastYardbirdScpc, controller } from "./controller"
import {
@ -140,7 +139,7 @@ if (getFlag("developmentLogRequests")) {
app.use("/_wf", webFeaturesRouter)
app.get("/", (req: Request, res) => {
app.get("/", (_: Request, res) => {
if (PEACOCK_DEV) {
res.contentType("text/html")
res.send(
@ -240,21 +239,19 @@ app.post(
"/api/metrics/*",
jsonMiddleware({ limit: "10Mb" }),
(req: RequestWithJwt<never, S2CEventWithTimestamp[]>, res) => {
req.body.forEach((event) => {
for (const event of req.body) {
controller.hooks.newMetricsEvent.call(event, req)
})
}
res.send()
},
)
app.use("/oauth/token", urlencoded())
app.post("/oauth/token", (req: RequestWithJwt, res) =>
app.post("/oauth/token", urlencoded(), (req: RequestWithJwt, res) =>
handleOauthToken(req, res),
)
app.get("/files/onlineconfig.json", (req, res) => {
app.get("/files/onlineconfig.json", (_, res) => {
res.set("Content-Type", "application/octet-stream")
res.send(getConfig("OnlineConfig", false))
})
@ -270,9 +267,7 @@ app.use(
req.serverVersion = req.params.serverVersion
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7") &&
// prettier-ignore
req.serverVersion !== "7.3.0"
: req.serverVersion.startsWith("7")
? // prettier-ignore
"h2"
: // prettier-ignore
@ -426,10 +421,6 @@ app.post(
const legacyRouter = Router()
const primaryRouter = Router()
legacyRouter.use(
"/authentication/api/userchannel/EventsService/",
legacyEventRouter,
)
legacyRouter.use("/resources-(\\d+-\\d+)/", legacyMenuSystemRouter)
legacyRouter.use("/authentication/api/userchannel/", legacyProfileRouter)
legacyRouter.use("/profiles/page/", legacyMenuDataRouter)
@ -456,7 +447,6 @@ primaryRouter.use(
reportRouter,
)
primaryRouter.use("/authentication/api/userchannel/", profileRouter)
primaryRouter.use("/profiles/page/", preMenuDataRouter)
primaryRouter.use("/profiles/page", multiplayerMenuDataRouter)
primaryRouter.use("/profiles/page/", menuDataRouter)
primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemPreRouter)
@ -464,7 +454,7 @@ primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemRouter)
app.use(
Router()
.use((req: RequestWithJwt, res, next) => {
.use((req: RequestWithJwt, _, next) => {
if (req.shouldCease) {
return next("router")
}
@ -477,7 +467,7 @@ app.use(
})
.use(legacyRouter),
Router()
.use((req: RequestWithJwt, res, next) => {
.use((req: RequestWithJwt, _, next) => {
if (req.shouldCease) {
return next("router")
}
@ -636,9 +626,7 @@ program
.description("packs an input file into a Challenge Resource Package")
.action((input, options: { output: string }) => {
const outputPath =
options.output !== ""
? options.output
: input.replace(/\.[^/\\.]+$/, ".crp")
options.output || input.replace(/\.[^/\\.]+$/, ".crp")
writeFileSync(
outputPath,
@ -655,9 +643,7 @@ program
.description("unpacks a Challenge Resource Package")
.action((input, options: { output: string }) => {
const outputPath =
options.output !== ""
? options.output
: input.replace(/\.[^/\\.]+$/, ".json")
options.output || input.replace(/\.[^/\\.]+$/, ".json")
writeFileSync(outputPath, JSON.stringify(unpack(readFileSync(input))))

File diff suppressed because it is too large Load Diff

View File

@ -21,16 +21,22 @@ import type {
CompletionData,
GameLocationsData,
GameVersion,
IHit,
JwtData,
MissionStory,
OpportunityStatistics,
PeacockLocationsData,
RequestWithJwt,
Unlockable,
} from "../types/types"
import { controller } from "../controller"
import { contractIdToHitObject, controller } from "../controller"
import { generateCompletionData } from "../contracts/dataGen"
import { getUserData } from "../databaseHandler"
import { ChallengeFilterType } from "../candle/challengeHelpers"
import { GetDestinationQuery } from "../types/gameSchemas"
import { createInventory } from "../inventory"
import { log, LogLevel } from "../loggingInterop"
import { no2016 } from "../contracts/escalations/escalationService"
import { missionsInLocations } from "../contracts/missionsInLocation"
type GameFacingDestination = {
ChallengeCompletion: {
@ -52,24 +58,26 @@ type GameFacingDestination = {
}
}
}
const missionStories = getConfig<Record<string, MissionStory>>(
"MissionStories",
false,
)
export function getDestinationCompletion(
parent: Unlockable,
child: Unlockable | undefined,
req: RequestWithJwt,
gameVersion: GameVersion,
jwt: JwtData,
) {
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const missionStories = getConfig<Record<string, MissionStory>>(
"MissionStories",
false,
)
const userData = getUserData(jwt.unique_name, gameVersion)
const challenges = controller.challengeService.getGroupedChallengeLists(
{
type: ChallengeFilterType.ParentLocation,
parent: parent.Id,
},
parent.Id,
req.gameVersion,
gameVersion,
)
const opportunities = Object.values(missionStories)
@ -90,7 +98,7 @@ export function getDestinationCompletion(
controller.challengeService.countTotalNCompletedChallenges(
challenges,
userData.Id,
req.gameVersion,
gameVersion,
)
return {
@ -138,11 +146,20 @@ export function getCompletionPercent(
: (100 * totalCompleted) / totalCompletables
}
export function destinationsMenu(req: RequestWithJwt): GameFacingDestination[] {
/**
* Get the list of destinations used by the `/profiles/page/Destinations` endpoint.
*
* @param gameVersion
* @param jwt
*/
export function getAllGameDestinations(
gameVersion: GameVersion,
jwt: JwtData,
): GameFacingDestination[] {
const result: GameFacingDestination[] = []
const locations = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
req.gameVersion,
gameVersion,
true,
)
@ -152,22 +169,22 @@ export function destinationsMenu(req: RequestWithJwt): GameFacingDestination[] {
"UI_LOCATION_PARENT_" + destination.substring(16) + "_NAME"
const template: GameFacingDestination = {
...getDestinationCompletion(parent, undefined, req),
...getDestinationCompletion(parent, undefined, gameVersion, jwt),
...{
CompletionData: generateCompletionData(
destination,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
),
Data:
req.gameVersion === "h1"
gameVersion === "h1"
? {
normal: {
ChallengeCompletion: undefined,
CompletionData: generateCompletionData(
destination,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
"mission",
"normal",
),
@ -176,8 +193,8 @@ export function destinationsMenu(req: RequestWithJwt): GameFacingDestination[] {
ChallengeCompletion: undefined,
CompletionData: generateCompletionData(
destination,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
"mission",
"pro1",
),
@ -190,7 +207,7 @@ export function destinationsMenu(req: RequestWithJwt): GameFacingDestination[] {
// TODO: THIS IS NOT CORRECT FOR 2016!
// There are different challenges for normal and pro1 in 2016, right now, we do not support this.
// We're just reusing this for now.
if (req.gameVersion === "h1") {
if (gameVersion === "h1") {
template.Data.normal.ChallengeCompletion =
template.ChallengeCompletion
template.Data.pro1.ChallengeCompletion =
@ -270,3 +287,233 @@ export function createLocationsData(
return finalData
}
// TODO: this is a mess, write docs and type explicitly
export function getDestination(
query: GetDestinationQuery,
gameVersion: GameVersion,
jwt: JwtData,
) {
const LOCATION = query.locationId
const locData = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
gameVersion,
false,
)
const locationData = locData.parents[LOCATION]
const masteryData = controller.masteryService.getMasteryDataForDestination(
query.locationId,
gameVersion,
jwt.unique_name,
query.difficulty,
)
const response = {
Location: {},
MissionData: {
...getDestinationCompletion(
locationData,
undefined,
gameVersion,
jwt,
),
...{ SubLocationMissionsData: [] },
},
ChallengeData: {
Children:
controller.challengeService.getChallengeDataForDestination(
query.locationId,
gameVersion,
jwt.unique_name,
),
},
MasteryData:
LOCATION !== "LOCATION_PARENT_ICA_FACILITY"
? gameVersion === "h1"
? masteryData[0]
: masteryData
: {},
DifficultyData: undefined,
}
if (gameVersion === "h1" && LOCATION !== "LOCATION_PARENT_ICA_FACILITY") {
const inventory = createInventory(jwt.unique_name, gameVersion)
response.DifficultyData = {
AvailableDifficultyModes: [
{
Name: "normal",
Available: true,
},
{
Name: "pro1",
Available: inventory.some(
(e) =>
e.Unlockable.Id ===
locationData.Properties.DifficultyUnlock.pro1,
),
},
],
Difficulty: 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.Location = locationData
if (query.difficulty === "pro1") {
const obj = {
Location: locationData,
SubLocation: locationData,
Missions: [controller.missionsInLocations.pro1[LOCATION]].map(
(id) => contractIdToHitObject(id, gameVersion, jwt.unique_name),
),
SarajevoSixMissions: [],
ElusiveMissions: [],
EscalationMissions: [],
SniperMissions: [],
PlaceholderMissions: [],
CampaignMissions: [],
CompletionData: generateCompletionData(
sublocationsData[0].Id,
jwt.unique_name,
gameVersion,
),
}
response.MissionData.SubLocationMissionsData.push(obj)
return response
}
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[] = [
...(gameVersion === "h1" && e.Id === "LOCATION_ICA_FACILITY"
? controller.missionsInLocations.escalations[
"LOCATION_ICA_FACILITY_SHIP"
]
: []),
...new Set<string>(
controller.missionsInLocations.escalations[e.Id] || [],
),
]
for (const escalation of allUniqueEscalations) {
if (gameVersion === "h1" && no2016.includes(escalation)) continue
const details = contractIdToHitObject(
escalation,
gameVersion,
jwt.unique_name,
)
if (details) {
escalations.push(details)
}
}
const sniperMissions: IHit[] = []
for (const sniperMission of controller.missionsInLocations.sniper[
e.Id
] ?? []) {
sniperMissions.push(
contractIdToHitObject(
sniperMission,
gameVersion,
jwt.unique_name,
),
)
}
const obj = {
Location: locationData,
SubLocation: e,
Missions: [],
SarajevoSixMissions: [],
ElusiveMissions: [],
EscalationMissions: escalations,
SniperMissions: sniperMissions,
PlaceholderMissions: [],
CampaignMissions: [],
CompletionData: generateCompletionData(
e.Id,
jwt.unique_name,
gameVersion,
),
}
const types = [
...[
[undefined, "Missions"],
["elusive", "ElusiveMissions"],
],
...((gameVersion === "h1" &&
missionsInLocations.sarajevo["h2016enabled"]) ||
gameVersion === "h3"
? [["sarajevo", "SarajevoSixMissions"]]
: []),
]
for (const t of types) {
let theMissions: string[] | undefined = !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 (
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) {
for (const c of theMissions.filter(
// removes snow festival on h1
(m) =>
m &&
!(
gameVersion === "h1" &&
m === "c414a084-a7b9-43ce-b6ca-590620acd87e"
),
)) {
const mission = contractIdToHitObject(
c,
gameVersion,
jwt.unique_name,
)
obj[t[1]].push(mission)
}
}
}
response.MissionData.SubLocationMissionsData.push(obj)
}
return response
}

211
components/menus/hub.ts Normal file
View File

@ -0,0 +1,211 @@
/*
* 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 type { GameVersion, JwtData, PeacockLocationsData } from "../types/types"
import { swapToBrowsingMenusStatus } from "../discordRp"
import { getUserData } from "../databaseHandler"
import { controller } from "../controller"
import { contractCreationTutorialId, getMaxProfileLevel } from "../utils"
import { getVersionedConfig } from "../configSwizzleManager"
import {
generateCompletionData,
generateUserCentric,
} from "../contracts/dataGen"
import { createLocationsData, getAllGameDestinations } from "./destinations"
import { makeCampaigns } from "./campaigns"
export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
swapToBrowsingMenusStatus(gameVersion)
const userdata = getUserData(jwt.unique_name, gameVersion)
const contractCreationTutorial =
gameVersion !== "scpc"
? controller.resolveContract(contractCreationTutorialId)!
: undefined
const locations = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
gameVersion,
true,
)
const career =
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,
gameVersion,
jwt.unique_name,
).length
) {
const completionData =
controller.masteryService.getLocationCompletion(
parent,
parent,
gameVersion,
jwt.unique_name,
parent.includes("SNUG") ? "evergreen" : "mission",
gameVersion === "h1" ? "normal" : undefined,
)
masteryData.push({
CompletionData: completionData,
...(gameVersion === "h1"
? {
Data: {
normal: {
CompletionData: completionData,
},
pro1: {
CompletionData:
controller.masteryService.getLocationCompletion(
parent,
parent,
gameVersion,
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.includes("SNUG_")
) {
continue
}
const parent = locations.children[child].Properties.ParentLocation
const location = locations.children[child]
const challenges = controller.challengeService.getChallengesForLocation(
child,
gameVersion,
)
const challengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
challenges,
jwt.unique_name,
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,
jwt.unique_name,
gameVersion,
),
})
}
return {
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: getAllGameDestinations(gameVersion, jwt),
CreateContractTutorial: generateUserCentric(
contractCreationTutorial,
jwt.unique_name,
gameVersion,
),
LocationsData: createLocationsData(gameVersion, true),
ProfileData: {
ChallengeData: {
Children: Object.values(career),
},
MasteryData: masteryData,
},
StoryData: makeCampaigns(gameVersion, jwt.unique_name),
FilterData: getVersionedConfig("FilterData", gameVersion, false),
StoreData: getVersionedConfig("StoreData", 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(gameVersion),
},
}
}

View File

@ -16,7 +16,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { MissionStory, RequestWithJwt, SceneConfig } from "../types/types"
import type {
CompiledChallengeTreeCategory,
GameVersion,
JwtData,
MissionManifest,
MissionStory,
SceneConfig,
Unlockable,
UserCentricContract,
UserProfile,
} from "../types/types"
import { log, LogLevel } from "../loggingInterop"
import { _legacyBull, _theLastYardbirdScpc, controller } from "../controller"
import {
@ -40,33 +50,79 @@ import {
unlockOrderComparer,
} from "../utils"
import type { Response } from "express"
import { createInventory, getUnlockableById } from "../inventory"
import { createSniperLoadouts } from "./sniper"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
import { resolveProfiles } from "../profileHandler"
import { PlanningQuery } from "../types/gameSchemas"
import { userAuths } from "../officialServerAuth"
export async function planningView(
req: RequestWithJwt<PlanningQuery>,
res: Response,
): Promise<void> {
if (!req.query.contractid || !req.query.resetescalation) {
res.status(400).send("invalid query")
return
}
export type PlanningError = { error: boolean }
export type PlanningGroupData = {
GroupId: string | undefined
GroupTitle: string | undefined
CompletedLevels: number | undefined
Completed: boolean | undefined
TotalLevels: number | undefined
BestScore: number | undefined
BestPlayer: string | undefined
BestLevel: number | undefined
}
export type GamePlanningData = {
Contract: MissionManifest
ElusiveContractState?: "not_completed" | string
UserCentric?: UserCentricContract
IsFirstInGroup: boolean
Creator: UserProfile
UserContract?: boolean
UnlockedEntrances?: string[]
UnlockedAgencyPickups?: string[]
Objectives?: unknown
GroupData?: PlanningGroupData
Entrances: Unlockable[]
Location: Unlockable
LoadoutData: unknown
LimitedLoadoutUnlockLevel: number
CharacterLoadoutData?: unknown | null
ChallengeData?: {
Children: CompiledChallengeTreeCategory[]
}
Currency?: {
Balance: number
}
PaymentDetails?: {
Currency: "Merces" | string
Amount: number | null
MaximumDeduction: number | null
Bonuses: null
Expenses: null
Entrance: null
Pickup: null
SideMission: null
}
OpportunityData?: unknown[]
PlayerProfileXpData?: {
XP: number
Level: number
MaxLevel: number
}
}
export async function getPlanningData(
contractId: string,
resetEscalation: boolean,
jwt: JwtData,
gameVersion: GameVersion,
): Promise<PlanningError | GamePlanningData> {
const entranceData = getConfig<SceneConfig>("Entrances", false)
const missionStories = getConfig<Record<string, MissionStory>>(
"MissionStories",
false,
)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const isForReset = req.query.resetescalation === "true"
const userData = getUserData(jwt.unique_name, gameVersion)
for (const ms in userData.Extensions.opportunityprogression) {
if (Object.keys(missionStories).includes(ms)) {
@ -75,26 +131,25 @@ export async function planningView(
}
let contractData =
req.gameVersion === "h1" &&
req.query.contractid === "42bac555-bbb9-429d-a8ce-f1ffdf94211c"
gameVersion === "h1" &&
contractId === "42bac555-bbb9-429d-a8ce-f1ffdf94211c"
? _legacyBull
: req.query.contractid === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
: contractId === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(req.query.contractid)
: controller.resolveContract(contractId)
if (isForReset) {
if (resetEscalation) {
const escalationGroupId =
contractData.Metadata.InGroup ?? contractData.Metadata.Id
resetUserEscalationProgress(userData, escalationGroupId)
writeUserData(req.jwt.unique_name, req.gameVersion)
writeUserData(jwt.unique_name, gameVersion)
// now reassign properties and continue
req.query.contractid =
controller.escalationMappings.get(escalationGroupId)["1"]
contractId = controller.escalationMappings.get(escalationGroupId)["1"]
contractData = controller.resolveContract(req.query.contractid)
contractData = controller.resolveContract(contractId)
}
if (!contractData) {
@ -104,15 +159,13 @@ export async function planningView(
// E.g. the user adds a contract to favorites, then deletes the files, then tries to load the contract again.
log(
LogLevel.WARN,
`Trying to download contract ${req.query.contractid} due to it not found locally.`,
`Trying to download contract ${contractId} due to it not found locally.`,
)
const user = userAuths.get(req.jwt.unique_name)
const user = userAuths.get(jwt.unique_name)
const resp = await user._useService(
`https://${getRemoteService(
req.gameVersion,
)}.hitman.io/profiles/page/Planning?contractid=${
req.query.contractid
}&resetescalation=false&forcecurrentcontract=false&errorhandling=false`,
gameVersion,
)}.hitman.io/profiles/page/Planning?contractid=${contractId}&resetescalation=false&forcecurrentcontract=false&errorhandling=false`,
true,
)
@ -121,20 +174,19 @@ export async function planningView(
}
if (!contractData) {
log(LogLevel.ERROR, `Not found: ${req.query.contractid}, .`)
res.status(400).send("no ct")
return
log(LogLevel.ERROR, `Not found: ${contractId}, .`)
return { error: true }
}
const groupData = {
GroupId: undefined as string | undefined,
GroupTitle: undefined as string | undefined,
CompletedLevels: undefined as number | undefined,
Completed: undefined as boolean | undefined,
TotalLevels: undefined as number | undefined,
BestScore: undefined as number | undefined,
BestPlayer: undefined as string | undefined,
BestLevel: undefined as number | undefined,
const groupData: PlanningGroupData = {
GroupId: undefined,
GroupTitle: undefined,
CompletedLevels: undefined,
Completed: undefined,
TotalLevels: undefined,
BestScore: undefined,
BestPlayer: undefined,
BestLevel: undefined,
}
const escalation = escalationTypes.includes(contractData.Metadata.Type)
@ -171,9 +223,8 @@ export async function planningView(
}
if (!contractData) {
log(LogLevel.WARN, `Unknown contract: ${req.query.contractid}`)
res.status(404).send("contract not found!")
return
log(LogLevel.WARN, `Unknown contract: ${contractId}`)
return { error: true }
}
const creatorProfile = (
@ -182,7 +233,7 @@ export async function planningView(
contractData.Metadata.CreatorUserId || "",
"fadb923c-e6bb-4283-a537-eb4d1150262e",
],
req.gameVersion,
gameVersion,
)
)[0]
@ -193,12 +244,9 @@ export async function planningView(
`Looking up details for contract - Location:${contractData.Metadata.Location} (${scenePath})`,
)
const sublocation = getSubLocationFromContract(
contractData,
req.gameVersion,
)
const sublocation = getSubLocationFromContract(contractData, gameVersion)
if (!Object.prototype.hasOwnProperty.call(entranceData, scenePath)) {
if (!entranceData[scenePath]) {
log(
LogLevel.ERROR,
`Could not find Entrance data for ${scenePath} (loc Planning)! This may cause an unhandled promise rejection.`,
@ -207,11 +255,7 @@ export async function planningView(
const entrancesInScene = entranceData[scenePath]
const typedInv = createInventory(
req.jwt.unique_name,
req.gameVersion,
sublocation,
)
const typedInv = createInventory(jwt.unique_name, gameVersion, sublocation)
const unlockedEntrances = typedInv
.filter((item) => item.Unlockable.Type === "access")
@ -229,10 +273,10 @@ export async function planningView(
// Default loadout
let currentLoadout = loadouts.getLoadoutFor(req.gameVersion)
let currentLoadout = loadouts.getLoadoutFor(gameVersion)
if (!currentLoadout) {
currentLoadout = loadouts.createDefault(req.gameVersion)
currentLoadout = loadouts.createDefault(gameVersion)
}
let pistol = "FIREARMS_HERO_PISTOL_TACTICAL_ICA_19"
@ -242,8 +286,6 @@ export async function planningView(
let briefcaseProp: string | undefined = undefined
let briefcaseId: string | undefined = undefined
const hasOwn = Object.prototype.hasOwnProperty.bind(currentLoadout.data)
const dlForLocation =
getFlag("loadoutSaving") === "LEGACY"
? // older default loadout setting (per-person)
@ -251,8 +293,10 @@ export async function planningView(
contractData.Metadata.Location
]
: // new loadout profiles system
hasOwn(contractData.Metadata.Location) &&
currentLoadout.data[contractData.Metadata.Location]
Object.hasOwn(
currentLoadout.data,
contractData.Metadata.Location,
) && currentLoadout.data[contractData.Metadata.Location]
if (dlForLocation) {
pistol = dlForLocation["2"]
@ -275,21 +319,21 @@ export async function planningView(
const userCentric = generateUserCentric(
contractData,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
)
const sniperLoadouts = createSniperLoadouts(
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
contractData,
)
if (req.gameVersion === "scpc") {
sniperLoadouts.forEach((loadout) => {
if (gameVersion === "scpc") {
for (const loadout of sniperLoadouts) {
loadout["LoadoutData"] = loadout["Loadout"]["LoadoutData"]
delete loadout["Loadout"]
})
}
}
let loadoutSlots = [
@ -368,6 +412,7 @@ export async function planningView(
/**
* Handles loadout lock for Miami and Hokkaido
* TODO: migrate this to use the actual game data.
*/
const limitedLoadoutUnlockLevelMap = {
LOCATION_MIAMI: 2,
@ -380,19 +425,19 @@ export async function planningView(
getFlag("enableMasteryProgression")
) {
const loadoutUnlockable = getUnlockableById(
req.gameVersion === "h1"
gameVersion === "h1"
? sublocation?.Properties?.NormalLoadoutUnlock[
contractData.Metadata.Difficulty ?? "normal"
]
: sublocation?.Properties?.NormalLoadoutUnlock,
req.gameVersion,
gameVersion,
)
if (loadoutUnlockable) {
const loadoutMasteryData =
controller.masteryService.getMasteryForUnlockable(
loadoutUnlockable,
req.gameVersion,
gameVersion,
)
const locationProgression =
@ -412,109 +457,96 @@ export async function planningView(
}
}
res.json({
template:
req.gameVersion === "h1"
? getConfig("LegacyPlanningTemplate", false)
: req.gameVersion === "scpc"
? getConfig("FrankensteinPlanningTemplate", false)
: null,
data: {
Contract: contractData,
ElusiveContractState: "not_completed",
UserCentric: userCentric,
IsFirstInGroup: escalation ? groupData.CompletedLevels === 0 : true,
Creator: creatorProfile,
UserContract: creatorProfile.DevId !== "IOI",
UnlockedEntrances:
contractData.Metadata.Type === "sniper"
? null
: typedInv
.filter(
(item) =>
item.Unlockable.Subtype ===
"startinglocation",
)
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
UnlockedAgencyPickups:
contractData.Metadata.Type === "sniper"
? null
: typedInv
.filter(
(item) => item.Unlockable.Type === "agencypickup",
)
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
Objectives: mapObjectives(
contractData.Data.Objectives,
contractData.Data.GameChangers || [],
contractData.Metadata.GroupObjectiveDisplayOrder || [],
contractData.Metadata.IsEvergreenSafehouse,
return {
Contract: contractData,
ElusiveContractState: "not_completed",
UserCentric: userCentric,
IsFirstInGroup: escalation ? groupData.CompletedLevels === 0 : true,
Creator: creatorProfile,
UserContract: creatorProfile.DevId !== "IOI",
UnlockedEntrances:
contractData.Metadata.Type === "sniper"
? null
: typedInv
.filter(
(item) =>
item.Unlockable.Subtype === "startinglocation",
)
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
UnlockedAgencyPickups:
contractData.Metadata.Type === "sniper"
? null
: typedInv
.filter((item) => item.Unlockable.Type === "agencypickup")
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
Objectives: mapObjectives(
contractData.Data.Objectives,
contractData.Data.GameChangers || [],
contractData.Metadata.GroupObjectiveDisplayOrder || [],
contractData.Metadata.IsEvergreenSafehouse,
),
GroupData: groupData,
Entrances:
contractData.Metadata.Type === "sniper"
? null
: unlockedEntrances
.filter((unlockable) =>
entrancesInScene.includes(
unlockable.Properties.RepositoryId,
),
)
.filter(
(unlockable) =>
unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.sort(unlockOrderComparer),
Location: sublocation,
LoadoutData:
contractData.Metadata.Type === "sniper" ? null : loadoutSlots,
LimitedLoadoutUnlockLevel:
limitedLoadoutUnlockLevelMap[sublocation.Id] ?? 0,
CharacterLoadoutData:
sniperLoadouts.length !== 0 ? sniperLoadouts : null,
ChallengeData: {
Children: controller.challengeService.getChallengeTreeForContract(
contractId,
gameVersion,
jwt.unique_name,
),
GroupData: groupData,
Entrances:
contractData.Metadata.Type === "sniper"
? null
: unlockedEntrances
.filter((unlockable) =>
entrancesInScene.includes(
unlockable.Properties.RepositoryId,
),
)
.filter(
(unlockable) =>
unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
)
.sort(unlockOrderComparer),
Location: sublocation,
LoadoutData:
contractData.Metadata.Type === "sniper" ? null : loadoutSlots,
LimitedLoadoutUnlockLevel:
limitedLoadoutUnlockLevelMap[sublocation.Id] ?? 0,
CharacterLoadoutData:
sniperLoadouts.length !== 0 ? sniperLoadouts : null,
ChallengeData: {
Children:
controller.challengeService.getChallengeTreeForContract(
req.query.contractid,
req.gameVersion,
req.jwt.unique_name,
),
},
Currency: {
Balance: 0,
},
PaymentDetails: {
Currency: "Merces",
Amount: 0,
MaximumDeduction: 85,
Bonuses: null,
Expenses: null,
Entrance: null,
Pickup: null,
SideMission: null,
},
OpportunityData: (contractData.Metadata.Opportunities || [])
.map((value) => missionStories[value])
.filter(Boolean),
PlayerProfileXpData: {
XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel,
MaxLevel: getMaxProfileLevel(req.gameVersion),
},
},
})
Currency: {
Balance: 0,
},
PaymentDetails: {
Currency: "Merces",
Amount: 0,
MaximumDeduction: 85,
Bonuses: null,
Expenses: null,
Entrance: null,
Pickup: null,
SideMission: null,
},
OpportunityData: (contractData.Metadata.Opportunities || [])
.map((value) => missionStories[value])
.filter(Boolean),
PlayerProfileXpData: {
XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP.ProfileLevel,
MaxLevel: getMaxProfileLevel(gameVersion),
},
}
}

View File

@ -21,11 +21,16 @@ import { generateUserCentric } from "../contracts/dataGen"
import { controller } from "../controller"
import type {
GameVersion,
JwtData,
MissionStory,
PlayNextCampaignDetails,
UserCentricContract,
} from "../types/types"
export const orderedMissions: string[] = [
/**
* Main story campaign ordered mission IDs.
*/
export const orderedMainCampaignMissions: string[] = [
"00000000-0000-0000-0000-000000000200",
"00000000-0000-0000-0000-000000000600",
"00000000-0000-0000-0000-000000000400",
@ -48,6 +53,9 @@ export const orderedMissions: string[] = [
"a3e19d55-64a6-4282-bb3c-d18c3f3e6e29",
]
/**
* Ordered Patient Zero campaign mission IDs.
*/
export const orderedPZMissions: string[] = [
"024b6964-a3bb-4457-b085-08f9a7dc7fb7",
"7e3f758a-2435-42de-93bd-d8f0b72c63a4",
@ -55,6 +63,9 @@ export const orderedPZMissions: string[] = [
"a2befcec-7799-4987-9215-6a152cb6a320",
]
/**
* Ordered sniper campaign mission IDs.
*/
export const sniperMissionIds: string[] = [
"ff9f46cf-00bd-4c12-b887-eac491c3a96d",
"00e57709-e049-44c9-a2c3-7655e19884fb",
@ -62,13 +73,9 @@ export const sniperMissionIds: string[] = [
]
/**
* Gets the ID for a season.
*
* @param index The index in orderedMissions.
* @returns The season's ID. ("1", "2", or "3")
* @see orderedMissions
* Gets the ID for a season based on the main mission index.
*/
export function getSeasonId(index: number): string {
function getSeasonId(index: number): string {
if (index <= 5) {
return "1"
}
@ -81,7 +88,7 @@ export function getSeasonId(index: number): string {
}
/**
* Generates a tile for play next given a contract ID and other details.
* Generates a "Continue Story" tile for play next given a contract ID and other details.
*
* @param userId The user's ID.
* @param contractId The next contract ID.
@ -89,12 +96,12 @@ export function getSeasonId(index: number): string {
* @param campaignInfo The campaign information.
* @returns The tile object.
*/
export function createPlayNextTile(
export function createPlayNextMission(
userId: string,
contractId: string,
gameVersion: GameVersion,
campaignInfo: PlayNextCampaignDetails,
) {
): PlayNextCategory {
return {
CategoryType: "NextMission",
CategoryName: "UI_PLAYNEXT_CONTINUE_STORY_TITLE",
@ -117,13 +124,32 @@ export function createPlayNextTile(
}
}
export type PlayNextCategory = {
CategoryType: "NextMission" | "MainOpportunity" | "MenuPage"
CategoryName: string
Items: {
ItemType: null | unknown
ContentType: "Contract" | "Opportunity" | "MenuPage"
Content: {
ContractId?: string
RepositoryId?: string
Name?: string
UserCentricContract?: UserCentricContract
CampaignInfo?: PlayNextCampaignDetails
}
CategoryType: "NextMission" | "MainOpportunity" | "MenuPage"
}[]
}
/**
* Generates tiles for recommended mission stories given a contract ID.
*
* @param contractId The contract ID.
* @returns The tile object.
*/
export function createMainOpportunityTile(contractId: string) {
export function createMainOpportunityTile(
contractId: string,
): PlayNextCategory {
const contractData = controller.resolveContract(contractId)
const missionStories = getConfig<Record<string, MissionStory>>(
@ -153,7 +179,7 @@ export function createMainOpportunityTile(contractId: string) {
* @param menuPages An array of menu page IDs.
* @returns The tile object
*/
export function createMenuPageTile(...menuPages: string[]) {
export function createMenuPageTile(...menuPages: string[]): PlayNextCategory {
// This is all based on what sniper does, not sure if any others have it.
return {
CategoryType: "MenuPage",
@ -168,3 +194,105 @@ export function createMenuPageTile(...menuPages: string[]) {
})),
}
}
export type GameFacingPlayNextData = {
Categories: PlayNextCategory[]
ProfileId: string
}
export function getGamePlayNextData(
contractId: string,
jwt: JwtData,
gameVersion: GameVersion,
): GameFacingPlayNextData {
const cats: PlayNextCategory[] = []
const currentIdIndex = orderedMainCampaignMissions.indexOf(contractId)
if (
currentIdIndex !== -1 &&
currentIdIndex !== orderedMainCampaignMissions.length - 1
) {
const nextMissionId = orderedMainCampaignMissions[currentIdIndex + 1]
const nextSeasonId = getSeasonId(currentIdIndex + 1)
let shouldContinue = true
// nextSeasonId > gameVersion's integer
if (parseInt(nextSeasonId) > parseInt(gameVersion[1])) {
shouldContinue = false
}
if (shouldContinue) {
cats.push(
createPlayNextMission(
jwt.unique_name,
nextMissionId,
gameVersion,
{
CampaignName: `UI_SEASON_${nextSeasonId}`,
},
),
)
}
cats.push(createMainOpportunityTile(contractId))
}
const pzIdIndex = orderedPZMissions.indexOf(contractId)
if (pzIdIndex !== -1 && pzIdIndex !== orderedPZMissions.length - 1) {
const nextMissionId = orderedPZMissions[pzIdIndex + 1]
cats.push(
createPlayNextMission(jwt.unique_name, nextMissionId, gameVersion, {
CampaignName: "UI_CONTRACT_CAMPAIGN_WHITE_SPIDER_TITLE",
ParentCampaignName: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE",
}),
)
}
// Atlantide mini-campaign (Miami Cottonmouth -> Skunk Gartersnake)
if (contractId === "f1ba328f-e3dd-4ef8-bb26-0363499fdd95") {
const nextMissionId = "0b616e62-af0c-495b-82e3-b778e82b5912"
cats.push(
createPlayNextMission(jwt.unique_name, nextMissionId, gameVersion, {
CampaignName: "UI_MENU_PAGE_SPECIAL_ASSIGNMENTS_TITLE",
ParentCampaignName: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE",
}),
)
}
if (sniperMissionIds.includes(contractId)) {
cats.push(createMenuPageTile("sniper"))
}
const pluginData = controller.hooks.getNextCampaignMission.call(
contractId,
gameVersion,
)
if (pluginData) {
if (pluginData.overrideIndex !== undefined) {
cats[pluginData.overrideIndex] = createPlayNextMission(
jwt.unique_name,
pluginData.nextContractId,
gameVersion,
pluginData.campaignDetails,
)
} else {
cats.push(
createPlayNextMission(
jwt.unique_name,
pluginData.nextContractId,
gameVersion,
pluginData.campaignDetails,
),
)
}
}
return {
Categories: cats,
ProfileId: jwt.unique_name,
}
}

View File

@ -0,0 +1,469 @@
/*
* 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 { createInventory, getUnlockableById, InventoryItem } from "../inventory"
import type {
GameVersion,
JwtData,
MissionManifest,
SafehouseCategory,
UserCentricContract,
} from "../types/types"
import {
SafehouseCategoryQuery,
StashpointQuery,
StashpointQueryH2016,
StashpointSlotName,
} from "../types/gameSchemas"
import { getDefaultSuitFor, uuidRegex } from "../utils"
import { controller } from "../controller"
import { generateUserCentric, getSubLocationByName } from "../contracts/dataGen"
import { log, LogLevel } from "../loggingInterop"
import { getUserData } from "../databaseHandler"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
/**
* Algorithm to get the stashpoint items data for H2 and H3.
*
* @param inventory The user's inventory.
* @param query The input query for the stashpoint.
* @param gameVersion
* @param contractData The optional contract data.
*/
export function getModernStashItemsData(
inventory: InventoryItem[],
query: StashpointQuery,
gameVersion: GameVersion,
contractData: MissionManifest | undefined,
) {
return inventory
.filter((item) => {
if (
(query.slotname === "gear" &&
contractData?.Peacock?.noGear === true) ||
(query.slotname === "concealedweapon" &&
contractData?.Peacock?.noCarriedWeapon === true)
) {
return false
}
if (
item.Unlockable.Subtype === "disguise" &&
gameVersion === "h3"
) {
return false
}
return (
item.Unlockable.Properties.LoadoutSlot && // only display items
(!query.slotname ||
((uuidRegex.test(query.slotid as string) || // container
query.slotname === "stashpoint") && // stashpoint
item.Unlockable.Properties.LoadoutSlot !==
"disguise") || // container or stashpoint => display all items
item.Unlockable.Properties.LoadoutSlot ===
query.slotname) && // else: display items for requested slot
(query.allowcontainers === "true" ||
!item.Unlockable.Properties.IsContainer) &&
(query.allowlargeitems === "true" ||
item.Unlockable.Properties.ItemSize === // regular gear slot or hidden stash => small item
"ITEMSIZE_SMALL" ||
(!item.Unlockable.Properties.ItemSize &&
item.Unlockable.Properties.LoadoutSlot !== // use old logic if itemsize is not set
"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: query.slotid,
SlotName: null,
}))
}
export type ModernStashData = {
SlotId: string | number
LoadoutItemsData: unknown
UserCentric?: UserCentricContract
ShowSlotName: string | number
}
/**
* Algorithm to get the stashpoint data for H2 and H3.
*
* @param query The stashpoint query.
* @param userId
* @param gameVersion
* @returns undefined if the query is invalid, or the stash data.
*/
export function getModernStashData(
query: StashpointQuery,
userId: string,
gameVersion: GameVersion,
): ModernStashData {
let contractData: MissionManifest | undefined = undefined
if (query.contractid) {
contractData = controller.resolveContract(query.contractid)
}
const inventory = createInventory(
userId,
gameVersion,
getSubLocationByName(contractData?.Metadata.Location, gameVersion),
)
if (query.slotname.endsWith(query.slotid!.toString())) {
query.slotname = query.slotname.slice(
0,
-query.slotid!.toString().length,
) // weird
}
const stashData: ModernStashData = {
SlotId: query.slotid,
LoadoutItemsData: {
SlotId: query.slotid,
Items: getModernStashItemsData(
inventory,
query,
gameVersion,
contractData,
),
Page: 0,
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData: {
stashpoint: query.stashpoint || "",
AllowLargeItems: query.allowlargeitems,
AllowContainers: query.allowcontainers, // ?? true
},
},
ShowSlotName: query.slotname,
}
if (contractData) {
stashData.UserCentric = generateUserCentric(
contractData,
userId,
gameVersion,
)
}
return stashData
}
/**
* Algorithm to get the stashpoint items data for H2016.
*
* @param inventory The user's inventory.
* @param slotname The slot name.
* @param query
* @param slotid The slot id.
*/
export function getLegacyStashItems(
inventory: InventoryItem[],
slotname: StashpointSlotName,
query: StashpointQueryH2016,
slotid: number,
) {
return inventory
.filter((item) => {
return (
item.Unlockable.Properties.LoadoutSlot && // only display items
(item.Unlockable.Properties.LoadoutSlot === slotname || // display items for requested slot
(slotname === "stashpoint" && // else: if stashpoint
item.Unlockable.Properties.LoadoutSlot !==
"disguise")) && // => display all non-disguise items
(query.allowlargeitems === "true" ||
item.Unlockable.Properties.ItemSize === // regular gear slot or hidden stash => small item
"ITEMSIZE_SMALL" ||
(!item.Unlockable.Properties.ItemSize &&
item.Unlockable.Properties.LoadoutSlot !== // use old logic if itemsize is not set
"carriedweapon")) &&
item.Unlockable.Type !== "challengemultipler" &&
!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: slotid.toString(),
SlotName: slotname,
}))
}
const loadoutSlots: StashpointSlotName[] = [
"carriedweapon",
"carrieditem",
"concealedweapon",
"disguise",
"gear",
"gear",
"stashpoint",
]
/**
* Algorithm to get the stashpoint data for H2016.
*
* @param query The stashpoint query.
* @param userId
* @param gameVersion
*/
export function getLegacyStashData(
query: StashpointQueryH2016,
userId: string,
gameVersion: GameVersion,
) {
const contractData = controller.resolveContract(query.contractid)
if (!contractData) {
return undefined
}
if (!loadoutSlots.includes(query.slotname.slice(0, -1))) {
log(
LogLevel.ERROR,
`Unknown slotname in legacy stashpoint: ${query.slotname}`,
)
return undefined
}
const userProfile = getUserData(userId, gameVersion)
const sublocation = getSubLocationByName(
contractData.Metadata.Location,
gameVersion,
)
const inventory = createInventory(userId, gameVersion, sublocation)
const userCentricContract = generateUserCentric(
contractData,
userId,
gameVersion,
)
const defaultLoadout = {
2: "FIREARMS_HERO_PISTOL_TACTICAL_001_SU_SKIN01",
3: getDefaultSuitFor(sublocation),
4: "TOKEN_FIBERWIRE",
5: "PROP_TOOL_COIN",
}
const getLoadoutItem = (id: number) => {
if (getFlag("loadoutSaving") === "LEGACY") {
const dl = userProfile.Extensions.defaultloadout
if (!dl) {
return defaultLoadout[id]
}
const forLocation = (userProfile.Extensions.defaultloadout || {})[
sublocation?.Properties?.ParentLocation
]
return (forLocation || defaultLoadout)[id]
} else {
let dl = loadouts.getLoadoutFor("h1")
if (!dl) {
dl = loadouts.createDefault("h1")
}
const forLocation = dl.data[sublocation?.Properties?.ParentLocation]
return (forLocation || defaultLoadout)[id]
}
}
return {
ContractId: query.contractid,
// the game actually only needs the loadoutdata from the requested slotid, but this is what IOI servers do
LoadoutData: [...loadoutSlots.entries()].map(([slotid, slotname]) => ({
SlotName: slotname,
SlotId: slotid.toString(),
Items: getLegacyStashItems(inventory, slotname, query, slotid),
Page: 0,
Recommended: getLoadoutItem(slotid)
? {
item: getUnlockableById(
getLoadoutItem(slotid),
gameVersion,
),
type: loadoutSlots[slotid],
owned: true,
}
: null,
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData:
slotid === 6
? {
stashpoint: query.stashpoint,
AllowLargeItems:
query.allowlargeitems || !query.stashpoint,
}
: {},
})),
Contract: userCentricContract.Contract,
ShowSlotName: query.slotname,
UserCentric: userCentricContract,
}
}
export function getSafehouseCategory(
query: SafehouseCategoryQuery,
gameVersion: GameVersion,
jwt: JwtData,
) {
const inventory = createInventory(jwt.unique_name, gameVersion)
let safehouseData: SafehouseCategory = {
Category: "_root",
SubCategories: [],
IsLeaf: false,
Data: null,
}
for (const item of inventory) {
if (query.type) {
// if type is specified in query
if (item.Unlockable.Type !== query.type) {
continue // skip all items that are not that type
}
if (query.subtype && item.Unlockable.Subtype !== 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" && gameVersion === "h3") {
continue // I don't want to put this in that elif statement
}
let category = safehouseData.SubCategories.find(
(cat) => cat.Category === item.Unlockable.Type,
)
let subcategory
if (!category) {
category = {
Category: item.Unlockable.Type,
SubCategories: [],
IsLeaf: false,
Data: null,
}
safehouseData.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.SubCategories.entries()) {
if (category.SubCategories.length === 1) {
// if category only has one subcategory
safehouseData.SubCategories[id] = category.SubCategories[0] // flatten it
safehouseData.SubCategories[id].Category = category.Category // but keep the top category's name
}
}
if (safehouseData.SubCategories.length === 1) {
// if root has only one subcategory
safehouseData = safehouseData.SubCategories[0] // flatten it
}
return safehouseData
}

View File

@ -174,14 +174,14 @@ multiplayerRouter.post(
// join existing match
const match = activeMatches.get(req.body.matchId)!
match.Players.forEach((playerId) =>
for (const playerId of match.Players) {
enqueuePushMessage(playerId, {
MatchId: req.body.matchId,
Type: 1,
PlayerId: req.jwt.unique_name,
MatchData: null,
}),
)
})
}
match.Players.push(req.jwt.unique_name)
} else {
@ -233,12 +233,12 @@ multiplayerRouter.post(
},
)
multiplayerRouter.post("/RegisterToPreset", jsonMiddleware(), (req, res) => {
multiplayerRouter.post("/RegisterToPreset", jsonMiddleware(), (_, res) => {
// matchmaking
// TODO: implement matchmaking
// req.body.presetId
// req.body.lobbyId (this is just a timestamp?)
res.status(500).end()
res.status(501).end()
})
export function handleMultiplayerEvent(

View File

@ -232,12 +232,7 @@ export async function handleOauthToken(
userData.EpicId = req.body.epic_userid
}
if (
Object.prototype.hasOwnProperty.call(
userData.Extensions,
"inventory",
)
) {
if (Object.hasOwn(userData.Extensions, "inventory")) {
// @ts-expect-error No longer in the typedefs.
delete userData.Extensions.inventory
}

View File

@ -212,12 +212,7 @@ profileRouter.post(
const userdata = getUserData(req.jwt.unique_name, req.gameVersion)
for (const extension in req.body.extensionsData) {
if (
Object.prototype.hasOwnProperty.call(
req.body.extensionsData,
extension,
)
) {
if (Object.hasOwn(req.body.extensionsData, extension)) {
userdata.Extensions[extension] =
req.body.extensionsData[extension]
}

View File

@ -16,7 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Response } from "express"
import {
contractTypes,
DEFAULT_MASTERY_MAXLEVEL,
@ -39,9 +38,9 @@ import type {
ContractSession,
GameChanger,
GameVersion,
JwtData,
MissionManifest,
MissionManifestObjective,
RequestWithJwt,
Seconds,
} from "./types/types"
import {
@ -68,7 +67,7 @@ import {
MissionEndChallenge,
MissionEndDrop,
MissionEndEvergreen,
MissionEndResponse,
MissionEndResult,
} from "./types/score"
import { MasteryData } from "./types/mastery"
import { createInventory, InventoryItem, getUnlockablesById } from "./inventory"
@ -511,42 +510,45 @@ export function calculateSniperScore(
]
}
export async function missionEnd(
req: RequestWithJwt<MissionEndRequestQuery>,
res: Response,
): Promise<void> {
// TODO: For this entire function, add support for 2016 difficulties
// Resolve the contract session
if (!req.query.contractSessionId) {
res.status(400).end()
return
}
export type MissionEndError = { errorCode: number; error: string }
const sessionDetails = contractSessions.get(req.query.contractSessionId)
export async function getMissionEndData(
query: MissionEndRequestQuery,
jwt: JwtData,
gameVersion: GameVersion,
): Promise<MissionEndError | MissionEndResult> {
// TODO: For this entire function, add support for 2016 difficulties
const sessionDetails = contractSessions.get(query.contractSessionId)
if (!sessionDetails) {
res.status(404).send("contract session not found")
return
return {
errorCode: 404,
error: "contract session not found",
}
}
if (sessionDetails.userId !== req.jwt.unique_name) {
res.status(401).send("requested score for other user's session")
return
if (sessionDetails.userId !== jwt.unique_name) {
return {
errorCode: 401,
error: "requested score for other user's session",
}
}
// Resolve userdata
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const userData = getUserData(jwt.unique_name, gameVersion)
// Resolve contract data
const contractData =
req.gameVersion === "scpc" &&
gameVersion === "scpc" &&
sessionDetails.contractId === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(sessionDetails.contractId, true)
if (!contractData) {
res.status(404).send("contract not found")
return
return {
errorCode: 404,
error: "Contract not found",
}
}
// Handle escalation groups
@ -559,8 +561,11 @@ export async function missionEnd(
LogLevel.ERROR,
`Unregistered escalation group ${sessionDetails.contractId}`,
)
res.status(500).end()
return
return {
errorCode: 500,
error: "unregistered escalation group",
}
}
if (!userData.Extensions.PeacockEscalations[eGroupId]) {
@ -598,7 +603,7 @@ export async function missionEnd(
userData.Extensions.PeacockPlayedContracts[eGroupId] = history
writeUserData(req.jwt.unique_name, req.gameVersion)
writeUserData(jwt.unique_name, gameVersion)
} else if (contractTypes.includes(contractData.Metadata.Type)) {
// Update the contract in the played list
const id = contractData.Metadata.Id
@ -612,7 +617,7 @@ export async function missionEnd(
Completed: true,
}
writeUserData(req.jwt.unique_name, req.gameVersion)
writeUserData(jwt.unique_name, gameVersion)
}
const levelData = controller.resolveContract(
@ -623,7 +628,7 @@ export async function missionEnd(
// Resolve the id of the parent location
const subLocation = getSubLocationByName(
levelData.Metadata.Location,
req.gameVersion,
gameVersion,
)
const locationParentId = subLocation
@ -631,8 +636,10 @@ export async function missionEnd(
: levelData.Metadata.Location
if (!locationParentId) {
res.status(404).send("location parentid not found")
return
return {
errorCode: 400,
error: "location parentid not found",
}
}
// Resolve all opportunities for the location
@ -652,27 +659,27 @@ export async function missionEnd(
parent: locationParentId,
},
locationParentId,
req.gameVersion,
gameVersion,
)
const contractChallenges =
controller.challengeService.getChallengesForContract(
sessionDetails.contractId,
req.gameVersion,
req.jwt.unique_name,
gameVersion,
jwt.unique_name,
sessionDetails.difficulty,
)
const locationChallengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
locationChallenges,
userData.Id,
req.gameVersion,
gameVersion,
)
const contractChallengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
contractChallenges,
userData.Id,
req.gameVersion,
gameVersion,
)
const locationPercentageComplete = getCompletionPercent(
@ -688,7 +695,7 @@ export async function missionEnd(
// Calculate XP based on global challenges.
const calculateXpResult: CalculateXpResult = calculateGlobalXp(
sessionDetails,
req.gameVersion,
gameVersion,
)
let justTickedChallenges = 0
let totalXpGain = calculateXpResult.xp
@ -711,8 +718,7 @@ export async function missionEnd(
)
})
.forEach((challengeData) => {
const userId = req.jwt.unique_name
const gameVersion = req.gameVersion
const userId = jwt.unique_name
userData.Extensions.ChallengeProgression[challengeData.Id].Ticked =
true
@ -737,10 +743,10 @@ export async function missionEnd(
let completionData = generateCompletionData(
levelData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
contractData.Metadata.Type,
req.query.masteryUnlockableId,
query.masteryUnlockableId,
)
// Calculate the old location progression based on the current one and process it
@ -752,17 +758,17 @@ export async function missionEnd(
const newLocationXp = completionData.XP
let newLocationLevel = levelForXp(newLocationXp)
if (!req.query.masteryUnlockableId) {
if (!query.masteryUnlockableId) {
userData.Extensions.progression.Locations[
locationParentId
].PreviouslySeenXp = newLocationXp
}
writeUserData(req.jwt.unique_name, req.gameVersion)
writeUserData(jwt.unique_name, gameVersion)
const masteryData = controller.masteryService.getMasteryPackage(
locationParentId,
req.gameVersion,
gameVersion,
)
let maxLevel = 1
@ -770,9 +776,9 @@ export async function missionEnd(
if (masteryData) {
maxLevel =
(req.query.masteryUnlockableId
(query.masteryUnlockableId
? masteryData.SubPackages.find(
(subPkg) => subPkg.Id === req.query.masteryUnlockableId,
(subPkg) => subPkg.Id === query.masteryUnlockableId,
).MaxLevel
: masteryData.MaxLevel) || DEFAULT_MASTERY_MAXLEVEL
@ -815,7 +821,7 @@ export async function missionEnd(
// Calculate score and summary
const calculateScoreResult = calculateScore(
req.gameVersion,
gameVersion,
sessionDetails,
contractData,
timeTotal,
@ -923,8 +929,8 @@ export async function missionEnd(
if (contractData.Metadata.Type === "sniper") {
const userInventory = createInventory(
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
undefined,
)
@ -943,7 +949,7 @@ export async function missionEnd(
sessionDetails,
userData,
locationParentId,
req.query.masteryUnlockableId,
query.masteryUnlockableId,
)
// Update completion data with latest mastery
@ -953,10 +959,10 @@ export async function missionEnd(
// Temporarily get completion data for the unlockable
completionData = generateCompletionData(
levelData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
"sniper", // We know the type will be sniper.
req.query.masteryUnlockableId,
query.masteryUnlockableId,
)
newLocationLevel = completionData.Level
unlockableProgression = {
@ -972,16 +978,16 @@ export async function missionEnd(
}
userData.Extensions.progression.Locations[locationParentId][
req.query.masteryUnlockableId
query.masteryUnlockableId
].PreviouslySeenXp = completionData.XP
writeUserData(req.jwt.unique_name, req.gameVersion)
writeUserData(jwt.unique_name, gameVersion)
// Set the completion data to the location so the end screen formats properly.
completionData = generateCompletionData(
levelData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
jwt.unique_name,
gameVersion,
)
// Override the contract score
@ -1003,9 +1009,9 @@ export async function missionEnd(
const masteryData =
controller.masteryService.getMasteryDataForSubPackage(
locationParentId,
req.query.masteryUnlockableId ?? undefined,
req.gameVersion,
req.jwt.unique_name,
query.masteryUnlockableId ?? undefined,
gameVersion,
jwt.unique_name,
) as MasteryData
if (masteryData) {
@ -1024,10 +1030,7 @@ export async function missionEnd(
const challengeDrops: MissionEndDrop[] =
calculateXpResult.completedChallenges.reduce((acc, challenge) => {
if (challenge?.Drops?.length) {
const drops = getUnlockablesById(
challenge.Drops,
req.gameVersion,
)
const drops = getUnlockablesById(challenge.Drops, gameVersion)
delete challenge.Drops
for (const drop of drops) {
@ -1042,7 +1045,7 @@ export async function missionEnd(
}, [])
// Setup the result
const result: MissionEndResponse = {
const result: MissionEndResult = {
MissionReward: {
LocationProgression: {
LevelInfo: locationLevelInfo,
@ -1082,7 +1085,7 @@ export async function missionEnd(
XPGain: 0,
ChallengesCompleted: justTickedChallenges,
LocationHideProgression: masteryData?.HideProgression || false,
ProdileId1: req.jwt.unique_name,
ProdileId1: jwt.unique_name,
stars: calculateScoreResult.stars,
ScoreDetails: {
Headlines: calculateScoreResult.scoringHeadlines,
@ -1136,11 +1139,11 @@ export async function missionEnd(
gameDifficulty: difficultyToString(
sessionDetails.difficulty,
),
gameVersion: req.gameVersion,
platform: req.jwt.platform,
gameVersion,
platform: jwt.platform,
username: userData.Gamertag,
platformId:
req.jwt.platform === "epic"
jwt.platform === "epic"
? userData.EpicId
: userData.SteamId,
score: calculateScoreResult.scoreWithBonus,
@ -1168,7 +1171,7 @@ export async function missionEnd(
SniperChallengeScore: sniperChallengeScore,
PlayStyle: result.ScoreOverview.PlayStyle || null,
Description: "UI_MENU_SCORE_CONTRACT_COMPLETED",
ContractSessionId: req.query.contractSessionId,
ContractSessionId: query.contractSessionId,
Percentile: {
Spread: Array(10).fill(0),
Index: 0,
@ -1192,11 +1195,5 @@ export async function missionEnd(
}
}
res.json({
template:
req.gameVersion === "scpc"
? getConfig("FrankensteinScoreOverviewTemplate", false)
: null,
data: result,
})
return result
}

View File

@ -104,3 +104,51 @@ export type LoadSaveBody = Partial<{
difficultyLevel: number
contractId: string
}>
/**
* Query params that `/profiles/page/Safehouse` gets.
* Roughly the same as {@link SafehouseCategoryQuery} but this route is only for H1.
*/
export type SafehouseQuery = {
type?: string
}
/**
* Query params that `/profiles/page/SafehouseCategory` (used for Career > Inventory and possibly some of the H1 stuff) gets.
*/
export type SafehouseCategoryQuery = {
type?: string
subtype?: string
}
/**
* Query params that `/profiles/page/Destination` gets.
*/
export type GetDestinationQuery = {
locationId: string
difficulty?: string
}
/**
* Query params that `/profiles/page/Leaderboards` gets.
*/
export type LeaderboardEntriesCommonQuery = {
contractid: string
difficultyLevel?: string
}
/**
* Query params that `/profiles/page/DebriefingLeaderboards` gets.
* Because ofc it's different. Thanks IOI.
*/
export type DebriefingLeaderboardsQuery = {
contractid: string
difficulty?: string
}
/**
* Query params that `/profiles/page/ChallengeLocation` gets.
*/
export type ChallengeLocationQuery = {
locationId: string
}

View File

@ -103,7 +103,7 @@ export interface MissionEndEvergreenPayout {
IsPrestige: boolean
}
export interface MissionEndResponse {
export interface MissionEndResult {
MissionReward: {
LocationProgression: {
LevelInfo: number[]

View File

@ -1141,14 +1141,11 @@ export interface CreateFromParamsBody {
}
}
export interface CommonSelectScreenConfig {
template: unknown
data?: {
Unlocked: string[]
Contract: MissionManifest
OrderedUnlocks: Unlockable[]
UserCentric: UserCentricContract
}
export interface SelectEntranceOrPickupData {
Unlocked: string[]
Contract: MissionManifest
OrderedUnlocks: Unlockable[]
UserCentric: UserCentricContract
}
export type CompiledIoiStatemachine = unknown

View File

@ -161,7 +161,6 @@ export function xpRequiredForLevel(level: number): number {
return Math.max(0, (level - 1) * XP_PER_LEVEL)
}
// TODO: Determine some mathematical function
export const EVERGREEN_LEVEL_INFO: number[] = [
0, 5000, 10000, 17000, 24000, 31000, 38000, 45000, 52000, 61000, 70000,
79000, 88000, 97000, 106000, 115000, 124000, 133000, 142000, 154000, 166000,
@ -189,11 +188,16 @@ export function evergreenLevelForXp(xp: number): number {
return 1
}
/**
* Get the amount of XP needed to reach a mastery level in Evergreen.
*
* @param level
* @returns The XP, as a number.
*/
export function xpRequiredForEvergreenLevel(level: number): number {
return EVERGREEN_LEVEL_INFO[level - 1]
}
// TODO: Determine some mathematical function
export const SNIPER_LEVEL_INFO: number[] = [
0, 50000, 150000, 500000, 1000000, 1700000, 2500000, 3500000, 5000000,
7000000, 9500000, 12500000, 16000000, 20000000, 25000000, 31000000,
@ -213,16 +217,17 @@ export function sniperLevelForXp(xp: number): number {
}
/**
* Get the number of xp needed to reach a level in sniper missions.
* @param level The level in question.
* @returns The xp, as a number.
* Get the amount of XP needed to reach a mastery level in sniper missions.
*
* @param level
* @returns The XP, as a number.
*/
export function xpRequiredForSniperLevel(level: number): number {
return SNIPER_LEVEL_INFO[level - 1]
}
/**
* Clamps the given value between a minimum and maximum value
* Clamps the given value between a minimum and maximum value.
*/
export function clampValue(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
@ -230,9 +235,10 @@ export function clampValue(value: number, min: number, max: number) {
/**
* Updates a user profile depending on the current version (if any).
* @param profile The userprofile to update
*
* @param profile The user profile to update
* @param gameVersion The game version
* @returns The updated user profile
* @returns The updated user profile.
*/
function updateUserProfile(
profile: UserProfile,
@ -350,6 +356,7 @@ function updateUserProfile(
/**
* Returns whether a location is a sniper location. Works for both parent and child locations.
*
* @param location The location ID string.
* @returns A boolean denoting the result.
*/
@ -382,7 +389,7 @@ export function castUserProfile(
"PeacockCompletedEscalations",
"CPD",
]) {
if (!Object.prototype.hasOwnProperty.call(j.Extensions, item)) {
if (!Object.hasOwn(j.Extensions, item)) {
log(LogLevel.DEBUG, `Err missing property ${item}`)
log(
LogLevel.WARN,
@ -420,7 +427,7 @@ export function castUserProfile(
// Fix Extensions.gamepersistentdata.HitsFilterType.
// None of the old profiles should have "MyPlaylist".
if (
!Object.prototype.hasOwnProperty.call(
!Object.hasOwn(
j.Extensions.gamepersistentdata.HitsFilterType,
"MyPlaylist",
)
@ -504,11 +511,13 @@ export const defaultSuits = {
/**
* Default suits that are attainable via challenges or mastery in this version.
*
* NOTE: Currently this is hardcoded. To allow for flexibility and extensibility, this should be generated in real-time
* using the Drops of challenges and masteries. However, that would require looping through all challenges and masteries
* for all default suits, which is slow. This is a trade-off.
* @param gameVersion The game version.
* @returns The default suits that are attainable via challenges or mastery.
*
* @param gameVersion The game version.
* @returns The default suits that are attainable via challenges or mastery.
*/
export function attainableDefaults(gameVersion: GameVersion): string[] {
return gameVersion === "h1"
@ -525,6 +534,7 @@ export function attainableDefaults(gameVersion: GameVersion): string[] {
/**
* Gets the default suit for a given sub-location and parent location.
* Priority is given to the sub-location, then the parent location, then 47's signature suit.
*
* @param subLocation The sub-location.
* @returns The default suit for the given sub-location and parent location.
*/
@ -591,6 +601,9 @@ export const gameDifficulty = {
* Casual mode.
*/
casual: 1,
/**
* Alias for {@link casual}.
*/
easy: 1,
/**
* Professional (normal) mode.
@ -600,6 +613,9 @@ export const gameDifficulty = {
* Master mode.
*/
master: 4,
/**
* Alias for {@link master}.
*/
hard: 4,
} as const

View File

@ -59,6 +59,7 @@ export async function generateRequireTable() {
"index.ts",
"generatedPeacockRequireTable.ts",
"types/globals.d.ts",
"types/*.ts",
],
})