1
mirror of https://github.com/thepeacockproject/Peacock synced 2025-02-23 03:35:25 +01:00
Peacock/components/profileHandler.ts
Lennard Fonteijn 4031779a91 Added distinction between Mastery XP and Action XP
Added extended Profile Profile to main menu
Added support for Payout objectives on the score screen
Added flag for unlocking all shortcuts
Added flag for unlocking all Freelancer masteries
Added flag to allow Peacock to be restarted when the game is running and connected
Fixed issue where playstyle wasn't show properly
2023-03-19 14:15:19 -04:00

872 lines
26 KiB
TypeScript

/*
* 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 path from "path"
import {
castUserProfile,
getMaxProfileLevel,
nilUuid,
uuidRegex,
XP_PER_LEVEL,
} from "./utils"
import { json as jsonMiddleware } from "body-parser"
import { getPlatformEntitlements } from "./platformEntitlements"
import { contractSessions, newSession } from "./eventHandler"
import type {
CompiledChallengeRuntimeData,
ContractSession,
GameVersion,
RequestWithJwt,
SaveFile,
UpdateUserSaveFileTableBody,
UserProfile,
} from "./types/types"
import { log, LogLevel } from "./loggingInterop"
import {
deleteContractSession,
getContractSession,
getUserData,
writeContractSession,
writeUserData,
} from "./databaseHandler"
import { randomUUID } from "crypto"
import { getVersionedConfig } from "./configSwizzleManager"
import { createInventory } from "./inventory"
import { controller } from "./controller"
import { loadouts } from "./loadouts"
import { getFlag } from "./flags"
import { menuSystemDatabase } from "./menus/menuSystem"
import {
compileRuntimeChallenge,
inclusionDataCheck,
} from "./candle/challengeHelpers"
import { LoadSaveBody } from "./types/gameSchemas"
const profileRouter = Router()
// /authentication/api/userchannel/
interface FakePlayer {
id: string
name: string
platformId: string
platform: string
}
/**
* The fake player registry allows us to translate another user's
* profile ID to their name, for leaderboards.
*/
export const fakePlayerRegistry: {
players: FakePlayer[]
getFromId(id: string): FakePlayer | undefined
index(
name: string,
platform: string,
platformId: string,
requestedId?: string,
): string
} = {
players: [],
getFromId(id: string): FakePlayer | undefined {
return this.players.find((p) => p.id === id)
},
index(
name: string,
platform: string,
platformId: string,
requestedId?: string,
): string {
if (!this.players.find((p) => p.name === name)) {
this.players.push({
name,
id: requestedId || randomUUID(),
platformId,
platform,
})
}
return this.players.find((p) => p.name === name)?.id || nilUuid
},
}
profileRouter.post(
"/AuthenticationService/GetBlobOfflineCacheDatabaseDiff",
(req: RequestWithJwt, res) => {
const configs = []
menuSystemDatabase.hooks.getDatabaseDiff.call(configs, req.gameVersion)
res.json(configs)
},
)
profileRouter.post("/ProfileService/SetClientEntitlements", (req, res) => {
res.json("null")
})
profileRouter.post(
"/ProfileService/GetPlatformEntitlements",
jsonMiddleware(),
getPlatformEntitlements,
)
profileRouter.post(
"/ProfileService/UpdateProfileStats",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.id) {
return res.status(403).end() // data submitted for different profile id
}
const userdata = getUserData(req.jwt.unique_name, req.gameVersion)
userdata.Gamertag = req.body.gamerTag
userdata.Extensions.achievements = req.body.achievements
writeUserData(req.jwt.unique_name, req.gameVersion)
res.status(204).end()
},
)
profileRouter.post(
"/ProfileService/SynchronizeOfflineUnlockables",
(req, res) => {
res.status(204).end()
},
)
profileRouter.post("/ProfileService/GetUserConfig", (req, res) => {
res.json({})
})
profileRouter.post(
"/ProfileService/GetProfile",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (req.body.id !== req.jwt.unique_name) {
res.status(403).end() // data requested for different profile id
log(
LogLevel.WARN,
`Profile request mismatch (malicious?) - issuer: ${req.jwt.unique_name} victim: ${req.body.id}`,
)
return
}
const userdata = getUserData(req.jwt.unique_name, req.gameVersion)
for (const extension in userdata.Extensions) {
if (
Object.prototype.hasOwnProperty.call(
userdata.Extensions,
extension,
) &&
!Object.prototype.hasOwnProperty.call(
req.body.extensions,
extension,
)
) {
delete userdata[extension]
}
}
res.json(userdata)
},
)
profileRouter.post(
"/UnlockableService/GetInventory",
(req: RequestWithJwt, res) => {
const exts = getUserData(
req.jwt.unique_name,
req.gameVersion,
).Extensions
res.json(
createInventory(req.jwt.unique_name, req.gameVersion, exts.entP),
)
},
)
profileRouter.post(
"/ProfileService/UpdateExtensions",
jsonMiddleware(),
(
req: RequestWithJwt<
Record<string, never>,
{ extensionsData: Record<string, unknown>; id: string }
>,
res,
) => {
if (req.body.id !== req.jwt.unique_name) {
// data requested for different profile id
res.status(403).end()
return
}
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,
)
) {
userdata.Extensions[extension] =
req.body.extensionsData[extension]
}
}
writeUserData(req.jwt.unique_name, req.gameVersion)
res.json(req.body.extensionsData)
},
)
profileRouter.post(
"/ProfileService/SynchroniseGameStats",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (req.body.profileId !== req.jwt.unique_name) {
// data requested for different profile id
res.status(403).end()
return
}
const userdata = getUserData(req.jwt.unique_name, req.gameVersion)
userdata.Extensions.gamepersistentdata.__stats = req.body.localStats
writeUserData(req.jwt.unique_name, req.gameVersion)
res.json({
Inventory: createInventory(
req.jwt.unique_name,
req.gameVersion,
userdata.Extensions.entP,
),
Stats: req.body.localStats,
})
},
)
export async function resolveProfiles(
profileIDs: string[],
gameVersion: GameVersion,
): Promise<UserProfile[]> {
// cast to non-undefined value
return <UserProfile[]>(
await Promise.allSettled(
profileIDs.map((id: string) => {
if (!uuidRegex.test(id)) {
return Promise.reject(
"Tried to resolve malformed profile id",
)
}
if (id === "fadb923c-e6bb-4283-a537-eb4d1150262e") {
// ioi dev account
return Promise.resolve({
Id: "fadb923c-e6bb-4283-a537-eb4d1150262e",
LinkedAccounts: {
dev: "IOI",
},
Extensions: {},
ETag: null,
Gamertag: null,
DevId: "IOI",
SteamId: null,
StadiaId: null,
EpicId: null,
NintendoId: null,
XboxLiveId: null,
PSNAccountId: null,
PSNOnlineId: null,
})
}
if (id === "a38faeaa-5b5b-4d7e-af90-329e98a26652") {
log(
LogLevel.WARN,
"The game tried to resolve the PeacockProject account, which should no longer be used!",
)
return Promise.resolve({
Id: "a38faeaa-5b5b-4d7e-af90-329e98a26652",
LinkedAccounts: {
dev: "PeacockProject",
},
Extensions: {},
ETag: null,
Gamertag: "PeacockProject",
DevId: "PeacockProject",
SteamId: null,
StadiaId: null,
EpicId: null,
NintendoId: null,
XboxLiveId: null,
PSNAccountId: null,
PSNOnlineId: null,
})
}
const fakePlayer = fakePlayerRegistry.getFromId(id)
if (fakePlayer) {
return Promise.resolve({
Id: id,
LinkedAccounts:
fakePlayer.platform === "epic"
? { epic: fakePlayer.platformId }
: { steam: fakePlayer.platformId },
Extensions: {},
ETag: null,
Gamertag: fakePlayer.name,
DevId: null,
SteamId:
fakePlayer.platform === "steam"
? fakePlayer.platformId
: null,
StadiaId: null,
EpicId:
fakePlayer.platform === "epic"
? fakePlayer.platformId
: null,
NintendoId: null,
XboxLiveId: null,
PSNAccountId: null,
PSNOnlineId: null,
})
}
try {
const p = getUserData(id, gameVersion)
if (p) return Promise.resolve(p)
return Promise.reject("No value")
} catch (e) {
return Promise.reject(e)
}
}),
)
)
.map((outcome: PromiseSettledResult<UserProfile>) => {
if (outcome.status !== "fulfilled") {
if (outcome.reason.code === "ENOENT") {
log(
LogLevel.ERROR,
`No such profile ${path.basename(
outcome.reason.path,
".json",
)}`,
)
}
return undefined
}
const fakeIds = [
"fadb923c-e6bb-4283-a537-eb4d1150262e",
"a38faeaa-5b5b-4d7e-af90-329e98a26652",
...fakePlayerRegistry.players.map((p) => p.id),
]
let userdata: UserProfile = outcome.value
if (!fakeIds.includes(outcome?.value?.Id)) {
userdata = castUserProfile(outcome.value)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
userdata.Extensions = {} as any
return userdata
})
.filter(Boolean) // filter out nulls
}
profileRouter.post(
"/ProfileService/ResolveProfiles",
jsonMiddleware(),
async (req: RequestWithJwt, res) => {
res.json(await resolveProfiles(req.body.profileIDs, req.gameVersion))
},
)
profileRouter.post(
"/ProfileService/ResolveGamerTags",
jsonMiddleware(),
async (req: RequestWithJwt, res) => {
const profiles = (await resolveProfiles(
req.body.profileIds,
req.gameVersion,
)) as UserProfile[]
const result = {
steam: {},
epic: {},
dev: {},
}
for (const profile of profiles) {
if (profile.LinkedAccounts.dev) {
result.dev[profile.Id] = ""
continue
}
if (profile.Gamertag) {
if (profile.EpicId) {
result.epic[profile.Id] = profile.Gamertag
continue
}
result.steam[profile.Id] = profile.Gamertag
}
}
res.json(result)
},
)
profileRouter.post("/ProfileService/GetFriendsCount", (req, res) =>
res.send("0"),
)
profileRouter.post(
"/GamePersistentDataService/GetData",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.userId) {
return res.status(403).end()
}
const userdata = getUserData(req.body.userId, req.gameVersion)
res.json(userdata.Extensions.gamepersistentdata[req.body.key])
},
)
profileRouter.post(
"/GamePersistentDataService/SaveData",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.userId) {
return res.status(403).end()
}
const userdata = getUserData(req.body.userId, req.gameVersion)
userdata.Extensions.gamepersistentdata[req.body.key] = req.body.data
writeUserData(req.body.userId, req.gameVersion)
res.json(null)
},
)
profileRouter.post(
"/ChallengesService/GetActiveChallengesAndProgression",
jsonMiddleware(),
(
req: RequestWithJwt<Record<string, never>, { contractId: string }>,
res,
) => {
if (!uuidRegex.test(req.body.contractId)) {
return res.status(404).send("invalid contract")
}
const json = controller.resolveContract(req.body.contractId)
if (!json) {
log(
LogLevel.ERROR,
`Unknown contract in GACP: ${req.body.contractId}`,
)
return res.status(404).send("contract not found")
}
if (json.Metadata.Type === "creation") {
return res.json([])
}
let challenges: CompiledChallengeRuntimeData[] = (
getVersionedConfig(
"GlobalChallenges",
req.gameVersion,
true,
) as CompiledChallengeRuntimeData[]
).filter((val) => inclusionDataCheck(val.Challenge.InclusionData, json))
challenges.push(
...Object.values(
controller.challengeService.getChallengesForContract(
json.Metadata.Id,
req.gameVersion,
),
)
.flat()
.map((challengeData) => {
return compileRuntimeChallenge(
challengeData,
controller.challengeService.getPersistentChallengeProgression(
req.jwt.unique_name,
challengeData.Id,
req.gameVersion,
),
)
}),
)
if (json.Metadata.AllowNonTargetKills) {
challenges = challenges.filter(
(c) =>
c.Challenge.Id !== "f929efad-5d5e-4fcb-9c4e-6eb61a01412c",
)
challenges.forEach((val) => {
// prettier-ignore
if (val.Challenge.Id === "b1a85feb-55af-4707-8271-b3522661c0b1") {
// prettier-ignore
val.Challenge.Definition!["States"]["Start"][
"CrowdNPC_Died"
]["Transition"] = "Success"
}
})
}
const unlockAllShortcuts = getFlag("gameplayUnlockAllShortcuts")
for (const challenge of challenges) {
if (
unlockAllShortcuts &&
challenge.Challenge.Tags?.includes("shortcut")
) {
challenge.Progression = {
ChallengeId: challenge.Challenge.Id,
ProfileId: req.jwt.unique_name,
Completed: true,
Ticked: true,
State: {
CurrentState: "Success",
},
// @ts-expect-error typescript hates dates
CompletedAt: new Date(new Date() - 10).toISOString(),
MustBeSaved: false,
}
} else {
challenge.Progression = Object.assign(
{
ChallengeId: challenge.Challenge.Id,
ProfileId: req.jwt.unique_name,
Completed: false,
State: {},
ETag: `W/"datetime'${encodeURIComponent(
new Date().toISOString(),
)}'"`,
CompletedAt: null,
MustBeSaved: false,
},
challenge.Progression,
)
}
}
res.json(challenges)
},
)
profileRouter.post(
"/HubPagesService/GetChallengeTreeFor",
jsonMiddleware(),
(req: RequestWithJwt, res) => {
res.json({
Data: {
Children:
controller.challengeService.getChallengeTreeForContract(
req.body.contractId,
req.gameVersion,
req.jwt.unique_name,
),
},
LevelsDefinition: {
//TODO: Add Evergreen LevelInfo here?
Location: [0],
PlayerProfile: {
Version: 1,
XpPerLevel: XP_PER_LEVEL,
MaxLevel: getMaxProfileLevel(req.gameVersion),
},
},
})
},
)
profileRouter.post(
"/DefaultLoadoutService/Set",
jsonMiddleware(),
async (req: RequestWithJwt, res) => {
if (getFlag("loadoutSaving") === "PROFILES") {
//#region Save with loadout profiles
let loadout = loadouts.getLoadoutFor(req.gameVersion)
if (!loadout) {
loadout = loadouts.createDefault(req.gameVersion)
}
loadout.data[req.body.location] = req.body.loadout
await loadouts.save()
//#endregion
} else {
//#region Save with legacy (per-user) system
const userdata = getUserData(req.jwt.unique_name, req.gameVersion)
if (userdata.Extensions.defaultloadout === undefined) {
userdata.Extensions.defaultloadout = {}
}
userdata.Extensions.defaultloadout[req.body.location] =
req.body.loadout
writeUserData(req.jwt.unique_name, req.gameVersion)
//#endregion
}
res.status(204).end()
},
)
profileRouter.post(
"/ProfileService/UpdateUserSaveFileTable",
jsonMiddleware(),
async (req: RequestWithJwt<never, UpdateUserSaveFileTableBody>, res) => {
if (req.body.clientSaveFileList.length > 0) {
// We are saving to the SaveFile with the most recent timestamp.
// Others are ignored.
const save: SaveFile = req.body.clientSaveFileList.reduce(
(prev: SaveFile, current: SaveFile) =>
prev.TimeStamp > current.TimeStamp ? prev : current,
)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
try {
await saveSession(save, userData)
// Successfully saved, so edit user data
if (!userData.Extensions.Saves) {
userData.Extensions.Saves = {}
}
userData.Extensions.Saves[save.Value.Name] = {
Timestamp: save.TimeStamp,
ContractSessionId: save.ContractSessionId,
Token: save.Value.LastEventToken,
}
writeUserData(req.jwt.unique_name, req.gameVersion)
} catch (e) {
if (getErrorCause(e) === "cause uninvestigated") {
log(LogLevel.DEBUG, `${getErrorMessage(e)}`)
} else {
log(
LogLevel.WARN,
`Unable to save session ${
save?.ContractSessionId
} because ${getErrorMessage(e)}.`,
)
}
}
}
res.status(204).end()
},
)
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
function getErrorCause(error: unknown) {
if (error instanceof Error) return error.cause
return String(error)
}
async function saveSession(
save: SaveFile,
userData: UserProfile,
): Promise<void> {
const sessionId = save.ContractSessionId
const token = save.Value.LastEventToken
const slot = save.Value.Name
if (!contractSessions.has(sessionId)) {
throw new Error("the session does not exist in the server's memory", {
cause: "non-existent",
})
}
if (!userData.Extensions.Saves) {
userData.Extensions.Saves = {}
}
if (slot in userData.Extensions.Saves) {
const delta = save.TimeStamp - userData.Extensions.Saves[slot].Timestamp
if (delta === 0) {
throw new Error(
`the client is accessing /ProfileService/UpdateUserSaveFileTable with nothing updated.`,
{ cause: "cause uninvestigated" },
)
} else if (delta < 0) {
throw new Error(`there is a newer save in slot ${slot}`, {
cause: "outdated",
})
} else {
// If we can delete the old save, then do it. If not, we can still proceed.
try {
await deleteContractSession(
slot +
"_" +
userData.Extensions.Saves[slot].Token +
"_" +
userData.Extensions.Saves[slot].ContractSessionId,
)
} catch (e) {
log(
LogLevel.DEBUG,
`Failed to delete old ${slot} save. ${getErrorMessage(e)}.`,
)
}
}
}
await writeContractSession(
slot + "_" + token + "_" + sessionId,
contractSessions.get(sessionId)!,
)
log(
LogLevel.DEBUG,
`Saved contract to slot ${slot} with token = ${token}, session id = ${sessionId}, start time = ${
contractSessions.get(sessionId).timerStart
}.`,
)
}
profileRouter.post(
"/ContractSessionsService/Load",
jsonMiddleware(),
async (req: RequestWithJwt<never, LoadSaveBody>, res) => {
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
if (
!req.body.contractSessionId ||
!req.body.saveToken ||
!req.body.contractId
) {
res.status(400).send("bad body")
return
}
try {
await loadSession(
req.body.contractSessionId,
req.body.saveToken,
userData,
)
} catch (e) {
log(
LogLevel.DEBUG,
`Failed to load contract with token = ${req.body.saveToken}, session id = ${req.body.contractSessionId} because ${e.message}`,
)
log(
LogLevel.WARN,
"No such save detected! Might be an official servers save.",
)
if (PEACOCK_DEV) {
log(
LogLevel.DEBUG,
`(Save-context: ${req.body.contractSessionId}; ${req.body.saveToken})`,
)
}
log(
LogLevel.WARN,
"Creating a fake session to avoid problems... scoring will not work!",
)
newSession(
req.body.contractSessionId,
req.body.contractId,
req.jwt.unique_name,
req.body.difficultyLevel!,
req.gameVersion,
false,
)
}
res.send(`"${req.body.contractSessionId}"`)
},
)
async function loadSession(
sessionId: string,
token: string,
userData: UserProfile,
sessionData?: ContractSession,
): Promise<void> {
if (!sessionData) {
sessionData = await getContractSession(token + "_" + sessionId)
}
// Update challenge progression with the user's latest progression data
for (const cid in sessionData.challengeContexts) {
// Make sure the ChallengeProgression is available, otherwise loading might fail!
userData.Extensions.ChallengeProgression[cid] ??= {
State: {},
Completed: false,
Ticked: false,
}
const scope =
controller.challengeService.getChallengeById(cid).Definition.Scope
if (
!userData.Extensions.ChallengeProgression[cid].Completed &&
(scope === "hit" || scope === "profile")
) {
sessionData.challengeContexts[cid].context =
userData.Extensions.ChallengeProgression[cid].State
}
}
contractSessions.set(sessionId, sessionData)
log(
LogLevel.DEBUG,
`Loaded contract with token = ${token}, session id = ${sessionId}, start time = ${
contractSessions.get(sessionId).timerStart
}.`,
)
}
profileRouter.post(
"/ProfileService/GetSemLinkStatus",
jsonMiddleware(),
(req, res) => {
res.json({
IsConfirmed: true,
LinkedEmail: "mail@example.com",
IOIAccountId: nilUuid,
IOIAccountBaseUrl: "https://account.ioi.dk",
})
},
)
export { profileRouter }