Enable strict types mode (#362)

Signed-off-by: Reece Dunham <me@rdil.rocks>
This commit is contained in:
Reece Dunham 2024-02-02 14:44:21 -05:00
parent 0585b35447
commit 5cc69434c6
No known key found for this signature in database
GPG Key ID: F087CB485320F19F
71 changed files with 3454 additions and 2960 deletions

View File

@ -44,11 +44,13 @@ module.exports = {
],
},
rules: {
"@typescript-eslint/prefer-optional-chain": "warn",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-extra-semi": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/prefer-ts-expect-error": "error",
"no-nested-ternary": "warn",
eqeqeq: "error",
"no-duplicate-imports": "warn",
"promise/always-return": "error",

View File

@ -33,6 +33,7 @@ const legacyContractRouter = Router()
legacyContractRouter.post(
"/GetForPlay",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
if (!uuidRegex.test(req.body.id)) {
res.status(400).end()
@ -130,6 +131,7 @@ legacyContractRouter.post(
legacyContractRouter.post(
"/Start",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
if (req.body.profileId !== req.jwt.unique_name) {
res.status(400).end() // requested for different user id

View File

@ -24,37 +24,9 @@ import { getParentLocationByName } from "../contracts/dataGen"
const legacyMenuDataRouter = Router()
legacyMenuDataRouter.get(
"/debriefingchallenges",
(
req: RequestWithJwt<{ contractSessionId: string; contractId: string }>,
res,
) => {
if (typeof req.query.contractId !== "string") {
res.status(400).send("invalid contractId")
return
}
// debriefingchallenges?contractSessionId=00000000000000-00000000-0000-0000-0000-000000000001&contractId=dd906289-7c32-427f-b689-98ae645b407f
res.json({
template: getConfig("LegacyDebriefingChallengesTemplate", false),
data: {
ChallengeData: {
// FIXME: This may not work correctly; I don't know the actual format so I'm assuming challenge tree
Children:
controller.challengeService.getChallengeTreeForContract(
req.query.contractId,
req.gameVersion,
req.jwt.unique_name,
),
},
},
})
},
)
legacyMenuDataRouter.get(
"/MasteryLocation",
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<{ locationId: string; difficulty: string }>, res) => {
const masteryData =
controller.masteryService.getMasteryDataForDestination(

View File

@ -1,46 +0,0 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2024 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 { join } from "path"
import md5File from "md5-file"
import { readFile } from "atomically"
const legacyMenuSystemRouter = Router()
// /resources-6-74/
legacyMenuSystemRouter.get(
"/dynamic_resources_pc_release_rpkg",
async (req, res) => {
const filePath = join(
PEACOCK_DEV ? process.cwd() : __dirname,
"resources",
"dynamic_resources_h1.rpkg",
)
const md5 = await md5File(filePath)
res.set("Content-Type", "application/octet-stream")
res.set("Content-MD5", Buffer.from(md5, "hex").toString("base64"))
res.send(await readFile(filePath))
},
)
export { legacyMenuSystemRouter }

View File

@ -38,6 +38,7 @@ const legacyProfileRouter = Router()
legacyProfileRouter.post(
"/ChallengesService/GetActiveChallenges",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
if (!uuidRegex.test(req.body.contractId)) {
return res.status(404).send("invalid contract")
@ -93,7 +94,13 @@ legacyProfileRouter.post(
legacyProfileRouter.post(
"/ChallengesService/GetProgression",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<never, LegacyGetProgressionBody>, res) => {
if (!Array.isArray(req.body.challengeids)) {
res.status(400).send("invalid body")
return
}
const legacyGlobalChallenges = getConfig<CompiledChallengeIngameData[]>(
"LegacyGlobalChallenges",
false,
@ -114,10 +121,11 @@ legacyProfileRouter.post(
MustBeSaved: false,
}))
/*
for (const challengeId of req.body.challengeids) {
const challenge =
controller.challengeService.getChallengeById(challengeId)
const challenge = controller.challengeService.getChallengeById(
challengeId,
"h1",
)
if (!challenge) {
log(
@ -128,7 +136,7 @@ legacyProfileRouter.post(
}
const progression =
controller.challengeService.getChallengeProgression(
controller.challengeService.getPersistentChallengeProgression(
req.jwt.unique_name,
challengeId,
req.gameVersion,
@ -138,19 +146,16 @@ legacyProfileRouter.post(
ChallengeId: challengeId,
ProfileId: req.jwt.unique_name,
Completed: progression.Completed,
Ticked: progression.Ticked,
State: progression.State,
ETag: `W/"datetime'${encodeURIComponent(
new Date().toISOString(),
)}'"`,
CompletedAt: progression.CompletedAt,
MustBeSaved: false,
MustBeSaved: progression.MustBeSaved,
})
}
*/
// TODO: atampy broke this - please fix
// update(RD) nov 18 '22: fixed but still missing challenges in
// 2016 engine (e.g. showstopper is missing 9, 5 of which are the
// classics I think, not sure about the other 4)
// TODO: HELP! Please DM rdil if you see this
res.json(challenges)
},

View File

@ -18,7 +18,6 @@
import {
ChallengeProgressionData,
CompiledChallengeRewardData,
CompiledChallengeRuntimeData,
InclusionData,
MissionManifest,
@ -28,19 +27,6 @@ import { SavedChallengeGroup } from "../types/challenges"
import { controller } from "../controller"
import { gameDifficulty, isSniperLocation } from "../utils"
// TODO: unused?
export function compileScoringChallenge(
challenge: RegistryChallenge,
): CompiledChallengeRewardData {
return {
ChallengeId: challenge.Id,
ChallengeName: challenge.Name,
ChallengeDescription: challenge.Description,
ChallengeImageUrl: challenge.ImageName,
XPGain: challenge.Rewards?.MasteryXP || 0,
}
}
export function compileRuntimeChallenge(
challenge: RegistryChallenge,
progression: ChallengeProgressionData,
@ -106,8 +92,8 @@ export type ChallengeFilterOptions =
* @returns A boolean as the result.
*/
export function inclusionDataCheck(
incData: InclusionData,
contract: MissionManifest,
incData: InclusionData | undefined,
contract: MissionManifest | undefined,
): boolean {
if (!incData) return true
if (!contract) return false
@ -174,9 +160,9 @@ function isChallengeInContract(
: {
...challenge.InclusionData,
ContractTypes:
challenge.InclusionData.ContractTypes.filter(
challenge.InclusionData?.ContractTypes?.filter(
(type) => type !== "tutorial",
),
) || [],
},
contract,
)
@ -184,14 +170,15 @@ function isChallengeInContract(
// Is this for the current contract or group contract?
const isForContract = (challenge.InclusionData?.ContractIds || []).includes(
contract.Metadata.Id,
contract?.Metadata.Id || "",
)
// Is this for the current contract type?
// As of v6.1.0, this is only used for ET challenges.
// We have to resolve the non-group contract, `contract` is the group contract
const isForContractType = (
challenge.InclusionData?.ContractTypes || []
).includes(controller.resolveContract(contractId).Metadata.Type)
).includes(controller.resolveContract(contractId)!.Metadata.Type)
// Is this a location-wide challenge?
// "location" is more widely used, but "parentlocation" is used in Ambrose and Berlin, as well as some "Discover XX" challenges.
@ -287,7 +274,7 @@ export function filterChallenge(
*/
export function mergeSavedChallengeGroups(
g1: SavedChallengeGroup,
g2: SavedChallengeGroup,
g2?: SavedChallengeGroup,
): SavedChallengeGroup {
return {
...g1,

View File

@ -49,7 +49,7 @@ import {
HandleEventOptions,
} from "@peacockproject/statemachine-parser"
import { ChallengeContext, SavedChallengeGroup } from "../types/challenges"
import { fastClone, isSniperLocation } from "../utils"
import { fastClone, gameDifficulty, isSniperLocation } from "../utils"
import {
ChallengeFilterOptions,
ChallengeFilterType,
@ -88,12 +88,13 @@ export abstract class ChallengeRegistry {
* @Key2 The challenge Id.
* @value A `RegistryChallenge` object.
*/
protected challenges: Map<GameVersion, Map<string, RegistryChallenge>> =
new Map([
["h1", new Map()],
["h2", new Map()],
["h3", new Map()],
])
protected challenges: Record<GameVersion, Map<string, RegistryChallenge>> =
{
h1: new Map(),
h2: new Map(),
h3: new Map(),
scpc: new Map(),
}
/**
* @Key1 Game version.
@ -101,14 +102,15 @@ export abstract class ChallengeRegistry {
* @Key3 The group Id.
* @Value A `SavedChallengeGroup` object.
*/
protected groups: Map<
protected groups: Record<
GameVersion,
Map<string, Map<string, SavedChallengeGroup>>
> = new Map([
["h1", new Map()],
["h2", new Map()],
["h3", new Map()],
])
> = {
h1: new Map(),
h2: new Map(),
h3: new Map(),
scpc: new Map(),
}
/**
* @Key1 Game version.
@ -116,28 +118,30 @@ export abstract class ChallengeRegistry {
* @Key3 The group Id.
* @Value A `Set` of challenge Ids.
*/
protected groupContents: Map<
protected groupContents: Record<
GameVersion,
Map<string, Map<string, Set<string>>>
> = new Map([
["h1", new Map()],
["h2", new Map()],
["h3", new Map()],
])
> = {
h1: new Map(),
h2: new Map(),
h3: new Map(),
scpc: new Map(),
}
/**
* @Key1 Game version.
* @Key2 The challenge Id.
* @Value An `array` of challenge Ids that Key2 depends on.
*/
protected readonly _dependencyTree: Map<
protected readonly _dependencyTree: Record<
GameVersion,
Map<string, readonly string[]>
> = new Map([
["h1", new Map()],
["h2", new Map()],
["h3", new Map()],
])
> = {
h1: new Map(),
h2: new Map(),
h3: new Map(),
scpc: new Map(),
}
protected constructor(protected readonly controller: Controller) {}
@ -147,13 +151,9 @@ export abstract class ChallengeRegistry {
location: string,
gameVersion: GameVersion,
): void {
if (!this.groupContents.has(gameVersion)) {
return
}
const gameChallenges = this.groupContents.get(gameVersion)
const gameChallenges = this.groupContents[gameVersion]
challenge.inGroup = groupId
this.challenges.get(gameVersion)?.set(challenge.Id, challenge)
this.challenges[gameVersion].set(challenge.Id, challenge)
if (!gameChallenges.has(location)) {
gameChallenges.set(location, new Map())
@ -176,34 +176,33 @@ export abstract class ChallengeRegistry {
location: string,
gameVersion: GameVersion,
): void {
if (!this.groups.has(gameVersion)) {
return
}
const gameGroups = this.groups.get(gameVersion)
const gameGroups = this.groups[gameVersion]
if (!gameGroups.has(location)) {
gameGroups.set(location, new Map())
}
gameGroups.get(location).set(group.CategoryId, group)
gameGroups.get(location)?.set(group.CategoryId, group)
}
getChallengeById(
challengeId: string,
gameVersion: GameVersion,
): RegistryChallenge | undefined {
return this.challenges.get(gameVersion)?.get(challengeId)
return this.challenges[gameVersion].get(challengeId)
}
/**
* Returns a list of all challenges unlockables
* This method retrieves all the unlockables associated with the challenges for a given game version.
* It iterates over all the challenges for the specified game version and for each challenge, it checks if there are any unlockables (Drops).
* If there are unlockables, it adds them to the accumulator object with the dropId as the key and the challenge Id as the value.
*
* @todo This is bad, untyped, and undocumented. Fix it.
* @param gameVersion - The version of the game for which to retrieve the unlockables.
* @returns {Record<string, string>} - An object where each key is an unlockable's id (dropId) and the corresponding value is the id of the challenge that unlocks it.
*/
getChallengesUnlockables(gameVersion: GameVersion) {
return [...this.challenges.get(gameVersion).values()].reduce(
(acc, challenge) => {
getChallengesUnlockables(gameVersion: GameVersion): Record<string, string> {
return [...this.challenges[gameVersion].values()].reduce(
(acc: Record<string, string>, challenge) => {
if (challenge?.Drops?.length) {
challenge.Drops.forEach(
(dropId) => (acc[dropId] = challenge.Id),
@ -228,15 +227,18 @@ export abstract class ChallengeRegistry {
location: string,
gameVersion: GameVersion,
): SavedChallengeGroup | undefined {
if (!this.groups.has(gameVersion)) {
return undefined
}
const gameGroups = this.groups[gameVersion]
const gameGroups = this.groups.get(gameVersion)
const mainGroup = gameGroups.get(location)?.get(groupId)
if (groupId === "feats" && gameVersion !== "h3") {
if (!mainGroup) {
// emergency bailout - shouldn't happen in practice
return undefined
}
return mergeSavedChallengeGroups(
gameGroups.get(location)?.get(groupId),
mainGroup,
gameGroups.get("GLOBAL_ESCALATION_CHALLENGES")?.get(groupId),
)
}
@ -255,8 +257,13 @@ export abstract class ChallengeRegistry {
// Included by default. Filtered later.
if (groupId === "classic" && location !== "GLOBAL_CLASSIC_CHALLENGES") {
if (!mainGroup) {
// emergency bailout - shouldn't happen in practice
return undefined
}
return mergeSavedChallengeGroups(
gameGroups.get(location)?.get(groupId),
mainGroup,
gameGroups.get("GLOBAL_CLASSIC_CHALLENGES")?.get(groupId),
)
}
@ -265,13 +272,18 @@ export abstract class ChallengeRegistry {
groupId === "elusive" &&
location !== "GLOBAL_ELUSIVES_CHALLENGES"
) {
if (!mainGroup) {
// emergency bailout - shouldn't happen in practice
return undefined
}
return mergeSavedChallengeGroups(
gameGroups.get(location)?.get(groupId),
mainGroup,
gameGroups.get("GLOBAL_ELUSIVES_CHALLENGES")?.get(groupId),
)
}
return gameGroups.get(location)?.get(groupId)
return mainGroup
}
public getGroupContentByIdLoc(
@ -279,11 +291,7 @@ export abstract class ChallengeRegistry {
location: string,
gameVersion: GameVersion,
): Set<string> | undefined {
if (!this.groupContents.has(gameVersion)) {
return undefined
}
const gameChalGC = this.groupContents.get(gameVersion)
const gameChalGC = this.groupContents[gameVersion]
if (groupId === "feats" && gameVersion !== "h3") {
return new Set([
@ -334,9 +342,16 @@ export abstract class ChallengeRegistry {
challengeId: string,
gameVersion: GameVersion,
): readonly string[] {
return this._dependencyTree.get(gameVersion)?.get(challengeId) || []
return this._dependencyTree[gameVersion].get(challengeId) || []
}
/**
* This method checks the heuristics of a challenge.
* It parses the context listeners of the challenge and if the challenge has any dependencies (other challenges that need to be completed before this one), it adds them to the dependency tree.
*
* @param challenge The challenge to check.
* @param gameVersion The game version this challenge belongs to.
*/
protected checkHeuristics(
challenge: RegistryChallenge,
gameVersion: GameVersion,
@ -344,9 +359,10 @@ export abstract class ChallengeRegistry {
const ctxListeners = ChallengeRegistry._parseContextListeners(challenge)
if (ctxListeners.challengeTreeIds.length > 0) {
this._dependencyTree
.get(gameVersion)
?.set(challenge.Id, ctxListeners.challengeTreeIds)
this._dependencyTree[gameVersion].set(
challenge.Id,
ctxListeners.challengeTreeIds,
)
}
}
@ -392,10 +408,11 @@ export class ChallengeService extends ChallengeRegistry {
}
/**
* Check if the challenge needs to be saved in the user's progression data
* i.e. challenges with scopes being "profile" or "hit".
* Check if the challenge needs to be saved in the user's progression data.
* Challenges with scopes "profile" or "hit".
*
* @param challenge The challenge.
* @returns Whether the challenge needs to be saved in the user's progression data.
* @returns Whether the challenge needs to be saved in the user's progression data.
*/
needSaveProgression(challenge: RegistryChallenge): boolean {
return (
@ -512,7 +529,7 @@ export class ChallengeService extends ChallengeRegistry {
challenges: [string, RegistryChallenge[]][],
gameVersion: GameVersion,
) {
const groups = this.groups.get(gameVersion).get(location)?.keys() ?? []
const groups = this.groups[gameVersion].get(location)?.keys() ?? []
for (const groupId of groups) {
// if this is the global group, skip it.
@ -543,9 +560,9 @@ export class ChallengeService extends ChallengeRegistry {
return challenge
}
return filterChallenge(filter, challenge)
? challenge
: undefined
const res = filterChallenge(filter, challenge)
return res ? challenge : undefined
})
.filter(Boolean) as RegistryChallenge[]
@ -570,10 +587,6 @@ export class ChallengeService extends ChallengeRegistry {
): GroupIndexedChallengeLists {
let challenges: [string, RegistryChallenge[]][] = []
if (!this.groups.has(gameVersion)) {
return {}
}
this.getGroupedChallengesByLoc(
filter,
location,
@ -622,25 +635,36 @@ export class ChallengeService extends ChallengeRegistry {
difficulty = 4,
): GroupIndexedChallengeLists {
const userData = getUserData(userId, gameVersion)
const contract = this.controller.resolveContract(contractId, true)
const contractGroup = this.controller.resolveContract(contractId, true)
const level =
contract.Metadata.Type === "arcade" &&
contract.Metadata.Id === contractId
? // contractData, being a group contract, has the same Id as the input id parameter.
// This means that we are requesting the challenges for the next level of the group
this.controller.resolveContract(
contract.Metadata.GroupDefinition.Order[
getUserEscalationProgress(userData, contractId) - 1
],
false,
)
: this.controller.resolveContract(contractId, false)
if (!contractGroup) {
return {}
}
assert.ok(contract)
let contract: MissionManifest | undefined
if (
contractGroup.Metadata.Type === "arcade" &&
contractGroup.Metadata.Id === contractId
) {
const currentLevel =
contractGroup.Metadata.GroupDefinition?.Order[
getUserEscalationProgress(userData, contractId) - 1
]
assert.ok(currentLevel, "expected current level ID in escalation")
contract = this.controller.resolveContract(currentLevel, false)
} else {
contract = this.controller.resolveContract(contractId, false)
}
if (!contract) {
return {}
}
const levelParentLocation = getSubLocationFromContract(
level,
contract,
gameVersion,
)?.Properties.ParentLocation
@ -651,12 +675,12 @@ export class ChallengeService extends ChallengeRegistry {
type: ChallengeFilterType.Contract,
contractId: contractId,
locationId:
contract.Metadata.Id ===
contractGroup.Metadata.Id ===
"aee6a16f-6525-4d63-a37f-225e293c6118" &&
gameVersion !== "h1"
? "LOCATION_ICA_FACILITY_SHIP"
: level.Metadata.Location,
isFeatured: contract.Metadata.Type === "featured",
: contract.Metadata.Location,
isFeatured: contractGroup.Metadata.Type === "featured",
difficulty,
},
levelParentLocation,
@ -676,17 +700,23 @@ export class ChallengeService extends ChallengeRegistry {
const parent = locations.children[child].Properties.ParentLocation
let contracts = isSniperLocation(child)
? this.controller.missionsInLocations.sniper[child]
: (this.controller.missionsInLocations[child] ?? [])
? // @ts-expect-error This is fine - we know it will be there
this.controller.missionsInLocations.sniper[child]
: // @ts-expect-error This is fine - we know it will be there
(this.controller.missionsInLocations[child] ?? [])
.concat(
// @ts-expect-error This is fine - we know it will be there
this.controller.missionsInLocations.escalations[child],
)
// @ts-expect-error This is fine - we know it will be there
.concat(this.controller.missionsInLocations.arcade[child])
if (!contracts) {
contracts = []
}
assert.ok(parent, "expected parent location")
return this.getGroupedChallengeLists(
{
type: ChallengeFilterType.Contracts,
@ -712,26 +742,31 @@ export class ChallengeService extends ChallengeRegistry {
session.difficulty,
)
if (contractJson.Metadata.Type === "evergreen") {
if (contractJson?.Metadata.Type === "evergreen") {
session.evergreen = {
payout: 0,
scoringScreenEndState: undefined,
scoringScreenEndState: null,
failed: false,
}
}
// TODO: Add this to getChallengesForContract without breaking the rest of Peacock?
challengeGroups["global"] = this.getGroupByIdLoc(
"global",
"GLOBAL",
session.gameVersion,
).Challenges.filter((val) =>
inclusionDataCheck(val.InclusionData, contractJson),
)
challengeGroups["global"] =
this.getGroupByIdLoc(
"global",
"GLOBAL",
session.gameVersion,
)?.Challenges.filter((val) =>
inclusionDataCheck(val.InclusionData, contractJson),
) || []
const profile = getUserData(session.userId, session.gameVersion)
for (const group of Object.keys(challengeGroups)) {
if (!challengeContexts) {
break
}
for (const challenge of challengeGroups[group]) {
challengeContexts[challenge.Id] = {
context: undefined,
@ -892,7 +927,7 @@ export class ChallengeService extends ChallengeRegistry {
contractId: string,
gameVersion: GameVersion,
userId: string,
difficulty = 4,
difficulty = gameDifficulty.master,
): CompiledChallengeTreeCategory[] {
const userData = getUserData(userId, gameVersion)
@ -902,18 +937,37 @@ export class ChallengeService extends ChallengeRegistry {
return []
}
const levelData =
let levelData: MissionManifest | undefined
if (
contractData.Metadata.Type === "arcade" &&
contractData.Metadata.Id === contractId
? // contractData, being a group contract, has the same Id as the input id parameter.
// This means that we are requesting the challenges for the next level of the group
this.controller.resolveContract(
contractData.Metadata.GroupDefinition.Order[
getUserEscalationProgress(userData, contractId) - 1
],
false,
)
: this.controller.resolveContract(contractId, false)
) {
const order =
contractData.Metadata.GroupDefinition?.Order[
getUserEscalationProgress(userData, contractId) - 1
]
if (!order) {
log(
LogLevel.WARN,
`Failed to get escalation order in CTREE [${contractData.Metadata.GroupDefinition?.Order}]`,
)
return []
}
levelData = this.controller.resolveContract(order, false)
} else {
levelData = this.controller.resolveContract(contractId, false)
}
if (!levelData) {
log(
LogLevel.WARN,
`Failed to get level data in CTREE [${contractId}]`,
)
return []
}
const subLocation = getSubLocationFromContract(levelData, gameVersion)
@ -1116,80 +1170,89 @@ export class ChallengeService extends ChallengeRegistry {
gameVersion,
)
return entries.map(([groupId, challenges], index) => {
const groupData = this.getGroupByIdLoc(
groupId,
location.Properties.ParentLocation ?? location.Id,
gameVersion,
)
const challengeProgressionData = challenges.map((challengeData) =>
this.getPersistentChallengeProgression(
userId,
challengeData.Id,
return entries
.map(([groupId, challenges], index) => {
const groupData = this.getGroupByIdLoc(
groupId,
location.Properties.ParentLocation ?? location.Id,
gameVersion,
),
)
)
const lastGroup = this.getGroupByIdLoc(
Object.keys(challengeLists)[index - 1],
location.Properties.ParentLocation ?? location.Id,
gameVersion,
)
const nextGroup = this.getGroupByIdLoc(
Object.keys(challengeLists)[index + 1],
location.Properties.ParentLocation ?? location.Id,
gameVersion,
)
if (!groupData) {
return undefined
}
return {
Name: groupData?.Name,
Description: groupData?.Description,
Image: groupData?.Image,
CategoryId: groupData?.CategoryId,
Icon: groupData?.Icon,
ChallengesCount: challenges.length,
CompletedChallengesCount: challengeProgressionData.filter(
(progressionData) => progressionData.Completed,
).length,
CompletionData: completion,
Location: location,
IsLocked: location.Properties.IsLocked || false,
ImageLocked: location.Properties.LockedIcon || "",
RequiredResources: location.Properties.RequiredResources!,
SwitchData: {
Data: {
Challenges: this.mapSwitchChallenges(
challenges,
const challengeProgressionData = challenges.map(
(challengeData) =>
this.getPersistentChallengeProgression(
userId,
challengeData.Id,
gameVersion,
compiler,
),
HasPrevious: index !== 0, // whether we are not at the first group
HasNext:
index !== Object.keys(challengeLists).length - 1, // whether we are not at the final group
PreviousCategoryIcon:
index !== 0 ? lastGroup?.Icon : "",
NextCategoryIcon:
index !== Object.keys(challengeLists).length - 1
? nextGroup?.Icon
: "",
CategoryData: {
Name: groupData.Name,
Image: groupData.Image,
Icon: groupData.Icon,
ChallengesCount: challenges.length,
CompletedChallengesCount:
challengeProgressionData.filter(
(progressionData) =>
progressionData.Completed,
).length,
)
const lastGroup = this.getGroupByIdLoc(
Object.keys(challengeLists)[index - 1],
location.Properties.ParentLocation ?? location.Id,
gameVersion,
)
const nextGroup = this.getGroupByIdLoc(
Object.keys(challengeLists)[index + 1],
location.Properties.ParentLocation ?? location.Id,
gameVersion,
)
return {
Name: groupData.Name,
Description: groupData.Description,
Image: groupData.Image,
CategoryId: groupData.CategoryId,
Icon: groupData.Icon,
ChallengesCount: challenges.length,
CompletedChallengesCount: challengeProgressionData.filter(
(progressionData) => progressionData.Completed,
).length,
CompletionData: completion,
Location: location,
IsLocked: location.Properties.IsLocked || false,
ImageLocked: location.Properties.LockedIcon || "",
RequiredResources: location.Properties.RequiredResources!,
SwitchData: {
Data: {
Challenges: this.mapSwitchChallenges(
challenges,
userId,
gameVersion,
compiler,
),
HasPrevious: index !== 0, // whether we are not at the first group
HasNext:
index !==
Object.keys(challengeLists).length - 1, // whether we are not at the final group
PreviousCategoryIcon:
index !== 0 ? lastGroup?.Icon : "",
NextCategoryIcon:
index !== Object.keys(challengeLists).length - 1
? nextGroup?.Icon
: "",
CategoryData: {
Name: groupData.Name,
Image: groupData.Image,
Icon: groupData.Icon,
ChallengesCount: challenges.length,
CompletedChallengesCount:
challengeProgressionData.filter(
(progressionData) =>
progressionData.Completed,
).length,
},
CompletionData: completion,
},
CompletionData: completion,
IsLeaf: true,
},
IsLeaf: true,
},
}
})
}
})
.filter(Boolean) as CompiledChallengeTreeCategory[]
}
compileRegistryChallengeTreeData(
@ -1201,7 +1264,7 @@ export class ChallengeService extends ChallengeRegistry {
): CompiledChallengeTreeData {
const drops = challenge.Drops.map((e) =>
getUnlockableById(e, gameVersion),
).filter(Boolean)
).filter(Boolean) as Unlockable[]
if (drops.length !== challenge.Drops.length) {
log(
@ -1255,7 +1318,7 @@ export class ChallengeService extends ChallengeRegistry {
gameVersion: GameVersion,
userId: string,
): CompiledChallengeTreeData {
let contract: MissionManifest | null
let contract: MissionManifest | undefined
if (challenge.Type === "contract") {
contract = this.controller.resolveContract(
@ -1264,39 +1327,40 @@ export class ChallengeService extends ChallengeRegistry {
// This is so we can remove unused data and make it more like official - AF
const meta = contract?.Metadata
contract = !contract
? null
: {
// The null is for escalations as we cannot currently get groups
Data: {
Bricks: contract.Data.Bricks,
DevOnlyBricks: null,
GameChangerReferences:
contract.Data.GameChangerReferences || [],
GameChangers: contract.Data.GameChangers || [],
GameDifficulties:
contract.Data.GameDifficulties || [],
},
Metadata: {
CreationTimestamp: null,
CreatorUserId: meta.CreatorUserId,
DebriefingVideo: meta.DebriefingVideo || "",
Description: meta.Description,
Drops: meta.Drops || null,
Entitlements: meta.Entitlements || [],
GroupTitle: meta.GroupTitle || "",
Id: meta.Id,
IsPublished: meta.IsPublished || true,
LastUpdate: null,
Location: meta.Location,
PublicId: meta.PublicId || "",
ScenePath: meta.ScenePath,
Subtype: meta.Subtype || "",
TileImage: meta.TileImage,
Title: meta.Title,
Type: meta.Type,
},
}
contract =
!contract || !meta
? undefined
: {
// The null is for escalations as we cannot currently get groups
Data: {
Bricks: contract.Data.Bricks,
DevOnlyBricks: null,
GameChangerReferences:
contract.Data.GameChangerReferences || [],
GameChangers: contract.Data.GameChangers || [],
GameDifficulties:
contract.Data.GameDifficulties || [],
},
Metadata: {
CreationTimestamp: null,
CreatorUserId: meta.CreatorUserId,
DebriefingVideo: meta.DebriefingVideo || "",
Description: meta.Description,
Drops: meta.Drops || null,
Entitlements: meta.Entitlements || [],
GroupTitle: meta.GroupTitle || "",
Id: meta.Id,
IsPublished: meta.IsPublished || true,
LastUpdate: null,
Location: meta.Location,
PublicId: meta.PublicId || "",
ScenePath: meta.ScenePath,
Subtype: meta.Subtype || "",
TileImage: meta.TileImage,
Title: meta.Title,
Type: meta.Type,
},
}
}
return {
@ -1375,12 +1439,11 @@ export class ChallengeService extends ChallengeRegistry {
if (challengeId === parentId) {
// we're checking the tree of the challenge that was just completed,
// so we need to skip it, or we'll get an infinite loop and hit
// the max call stack size
// so we need to skip it, or we'll get an infinite loop
return
}
const allDeps = this._dependencyTree.get(gameVersion)?.get(challengeId)
const allDeps = this._dependencyTree[gameVersion].get(challengeId)
assert.ok(allDeps, `No dep tree for ${challengeId}`)
if (!allDeps.includes(parentId)) {
@ -1393,10 +1456,17 @@ export class ChallengeService extends ChallengeRegistry {
// Check if the dependency tree is completed now
const dep = this.getChallengeById(challengeId, gameVersion)
const challengeDependency = this.getChallengeById(
challengeId,
gameVersion,
)
if (!challengeDependency) {
return
}
const { challengeCountData } =
ChallengeService._parseContextListeners(dep)
ChallengeService._parseContextListeners(challengeDependency)
// First check for challengecounter, then challengetree
const completed =
@ -1408,11 +1478,15 @@ export class ChallengeService extends ChallengeRegistry {
return
}
const challenge = this.getChallengeById(challengeId, gameVersion)
assert.ok(challenge, `No challenge for ${challengeId}`)
this.onChallengeCompleted(
session,
userData.Id,
gameVersion,
this.getChallengeById(challengeId, gameVersion),
challenge,
parentId,
)
}
@ -1464,18 +1538,20 @@ export class ChallengeService extends ChallengeRegistry {
true
}
// Always count the number of completions
if (session.challengeContexts[challenge.Id]) {
session.challengeContexts[challenge.Id].timesCompleted++
}
if (session.challengeContexts) {
// Always count the number of completions
if (session.challengeContexts[challenge.Id]) {
session.challengeContexts[challenge.Id].timesCompleted++
}
// If we have a Definition-scope with a Repeatable, we may want to restart it.
// TODO: Figure out what Base/Delta means. For now if Repeatable is set, we restart the challenge.
if (
challenge.Definition.Repeatable &&
session.challengeContexts[challenge.Id]
) {
session.challengeContexts[challenge.Id].state = "Start"
// If we have a Definition-scope with a Repeatable, we may want to restart it.
// TODO: Figure out what Base/Delta means. For now if Repeatable is set, we restart the challenge.
if (
challenge.Definition.Repeatable &&
session.challengeContexts[challenge.Id]
) {
session.challengeContexts[challenge.Id].state = "Start"
}
}
controller.progressionService.grantProfileProgression(
@ -1490,7 +1566,7 @@ export class ChallengeService extends ChallengeRegistry {
this.hooks.onChallengeCompleted.call(userId, challenge, gameVersion)
// Check if completing this challenge also completes any dependency trees depending on it
for (const depTreeId of this._dependencyTree.get(gameVersion).keys()) {
for (const depTreeId of this._dependencyTree[gameVersion].keys()) {
this.tryToCompleteChallenge(
session,
depTreeId,

View File

@ -21,17 +21,22 @@ import {
getSubLocationByName,
} from "../contracts/dataGen"
import { log, LogLevel } from "../loggingInterop"
import { getConfig, getVersionedConfig } from "../configSwizzleManager"
import { getVersionedConfig } from "../configSwizzleManager"
import { getUserData } from "../databaseHandler"
import {
LocationMasteryData,
MasteryData,
MasteryDataTemplate,
MasteryDrop,
MasteryPackage,
MasteryPackageDrop,
UnlockableMasteryData,
} from "../types/mastery"
import { CompletionData, GameVersion, Unlockable } from "../types/types"
import {
CompletionData,
GameVersion,
ProgressionData,
Unlockable,
} from "../types/types"
import {
clampValue,
DEFAULT_MASTERY_MAXLEVEL,
@ -42,6 +47,7 @@ import {
} from "../utils"
import { getUnlockablesById } from "../inventory"
import assert from "assert"
export class MasteryService {
/**
@ -150,22 +156,16 @@ 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,
userId: string,
): MasteryDataTemplate {
const location: Unlockable =
): LocationMasteryData {
const location =
getSubLocationByName(locationId, gameVersion) ??
getParentLocationByName(locationId, gameVersion)
const masteryDataTemplate: MasteryDataTemplate =
getConfig<MasteryDataTemplate>(
"MasteryDataForLocationTemplate",
false,
)
assert.ok(location, "cannot get mastery data for unknown location")
const masteryData = this.getMasteryData(
location.Properties.ParentLocation ?? location.Id,
@ -174,11 +174,8 @@ export class MasteryService {
)
return {
template: masteryDataTemplate,
data: {
Location: location,
MasteryData: masteryData,
},
Location: location,
MasteryData: masteryData,
}
}
@ -202,19 +199,12 @@ export class MasteryService {
// Get the user profile
const userProfile = getUserData(userId, gameVersion)
// @since v7.0.0 this has been commented out as the default profile should
// have all the required properties - AF
/* userProfile.Extensions.progression.Locations[locationParentId] ??= {
Xp: 0,
Level: 1,
PreviouslySeenXp: 0,
} */
const parent =
userProfile.Extensions.progression.Locations[locationParentId]
const completionData = subPackageId
? userProfile.Extensions.progression.Locations[locationParentId][
subPackageId
]
: userProfile.Extensions.progression.Locations[locationParentId]
const completionData: ProgressionData = subPackageId
? (parent[subPackageId as keyof typeof parent] as ProgressionData)
: (parent as ProgressionData)
const nextLevel: number = clampValue(
completionData.Level + 1,
@ -279,7 +269,7 @@ export class MasteryService {
"SniperUnlockables",
gameVersion,
false,
).find((unlockable) => unlockable.Id === subPackageId).Properties
).find((unlockable) => unlockable.Id === subPackageId)?.Properties
.Name
: undefined
@ -297,11 +287,11 @@ export class MasteryService {
: xpRequiredForLevel,
subPackageId,
),
Id: isSniper ? subPackageId : masteryPkg.LocationId,
Id: isSniper ? subPackageId! : masteryPkg.LocationId,
SubLocationId: isSniper ? "" : subLocationId,
HideProgression: masteryPkg.HideProgression || false,
IsLocationProgression: !isSniper,
Name: name,
Name: name!,
}
}
@ -347,7 +337,7 @@ export class MasteryService {
subPackageId?: string,
): MasteryData[] {
// Get the mastery data
const masteryPkg: MasteryPackage = this.getMasteryPackage(
const masteryPkg: MasteryPackage | undefined = this.getMasteryPackage(
locationParentId,
gameVersion,
)
@ -389,16 +379,23 @@ export class MasteryService {
}
// Get all unlockables with matching Ids
const unlockableData: Unlockable[] = getUnlockablesById(
const unlockableData = getUnlockablesById(
Array.from(dropIdSet),
gameVersion,
)
// Put all unlockabkes in a map for quick lookup
const unlockableMap = new Map(
unlockableData.map((unlockable) => [unlockable.Id, unlockable]),
const mapped: [string, Unlockable][] = unlockableData.map(
(unlockable) => {
return [unlockable?.Id, unlockable] as unknown as [
string,
Unlockable,
]
},
)
const unlockableMap: Map<string, Unlockable> = new Map(mapped)
const masteryData: MasteryData[] = []
if (masteryPkg.SubPackages) {
@ -418,17 +415,19 @@ export class MasteryService {
subPkg.Id,
)
masteryData.push({
CompletionData: completionData,
Drops: this.processDrops(
completionData.Level,
subPkg.Drops,
unlockableMap,
),
Unlockable: isSniper
? unlockableMap.get(subPkg.Id)
: undefined,
})
if (completionData) {
masteryData.push({
CompletionData: completionData,
Drops: this.processDrops(
completionData.Level,
subPkg.Drops,
unlockableMap,
),
Unlockable: isSniper
? unlockableMap.get(subPkg.Id)
: undefined,
})
}
}
} else {
// All sniper locations are subpackages, so we don't need to add "sniper"
@ -441,14 +440,16 @@ export class MasteryService {
locationParentId.includes("SNUG") ? "evergreen" : "mission",
)
masteryData.push({
CompletionData: completionData,
Drops: this.processDrops(
completionData.Level,
masteryPkg.Drops,
unlockableMap,
),
})
if (completionData) {
masteryData.push({
CompletionData: completionData,
Drops: this.processDrops(
completionData.Level,
masteryPkg.Drops || [],
unlockableMap,
),
})
}
}
return masteryData

View File

@ -19,7 +19,12 @@
import { getSubLocationByName } from "../contracts/dataGen"
import { controller } from "../controller"
import { getUnlockablesById, grantDrops } from "../inventory"
import type { ContractSession, UserProfile, GameVersion } from "../types/types"
import type {
ContractSession,
GameVersion,
Unlockable,
UserProfile,
} from "../types/types"
import {
clampValue,
DEFAULT_MASTERY_MAXLEVEL,
@ -70,7 +75,9 @@ export class ProgressionService {
if (dropIds.length > 0) {
grantDrops(
userProfile.Id,
getUnlockablesById(dropIds, contractSession.gameVersion),
getUnlockablesById(dropIds, contractSession.gameVersion).filter(
Boolean,
) as Unlockable[],
)
}
@ -85,7 +92,8 @@ export class ProgressionService {
subPkgId?: string,
) {
return subPkgId
? userProfile.Extensions.progression.Locations[location][subPkgId]
? // @ts-expect-error It is possible to index into an object with a string
userProfile.Extensions.progression.Locations[location][subPkgId]
: userProfile.Extensions.progression.Locations[location]
}
@ -179,25 +187,29 @@ export class ProgressionService {
if (masteryData) {
const previousLevel = locationData.Level
let newLocationXp = xpRequiredForLevel(maxLevel)
if (isEvergreenContract) {
newLocationXp = xpRequiredForEvergreenLevel(maxLevel)
} else if (sniperUnlockable) {
newLocationXp = xpRequiredForSniperLevel(maxLevel)
}
locationData.Xp = clampValue(
locationData.Xp + masteryXp + actionXp,
0,
isEvergreenContract
? xpRequiredForEvergreenLevel(maxLevel)
: sniperUnlockable
? xpRequiredForSniperLevel(maxLevel)
: xpRequiredForLevel(maxLevel),
newLocationXp,
)
locationData.Level = clampValue(
isEvergreenContract
? evergreenLevelForXp(locationData.Xp)
: sniperUnlockable
? sniperLevelForXp(locationData.Xp)
: levelForXp(locationData.Xp),
1,
maxLevel,
)
let newLocationLevel = levelForXp(newLocationXp)
if (isEvergreenContract) {
newLocationLevel = evergreenLevelForXp(newLocationXp)
} else if (sniperUnlockable) {
newLocationLevel = sniperLevelForXp(newLocationXp)
}
locationData.Level = clampValue(newLocationLevel, 1, maxLevel)
// If mastery level has gone up, check if there are available drop rewards and award them
if (locationData.Level > previousLevel) {
@ -205,13 +217,13 @@ export class ProgressionService {
contractSession.gameVersion,
isEvergreenContract,
sniperUnlockable
? masteryData.SubPackages.find(
? masteryData.SubPackages?.find(
(pkg) => pkg.Id === sniperUnlockable,
).Drops
: masteryData.Drops,
)?.Drops || []
: masteryData.Drops || [],
previousLevel,
locationData.Level,
)
).filter(Boolean) as Unlockable[]
grantDrops(userProfile.Id, masteryLocationDrops)
}
}

View File

@ -291,7 +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.hasOwn(configs, `H2${config}`)) {
if (gameVersion === "h2" && !configs[`H2${config}`]) {
return getConfig(config, clone)
}

View File

@ -52,7 +52,7 @@ import {
createTimeLimit,
TargetCreator,
} from "../statemachines/contractCreation"
import { createSniperLoadouts } from "../menus/sniper"
import { createSniperLoadouts, SniperCharacter } from "../menus/sniper"
import { GetForPlay2Body } from "../types/gameSchemas"
import assert from "assert"
import { getUserData } from "../databaseHandler"
@ -63,7 +63,8 @@ const contractRoutingRouter = Router()
contractRoutingRouter.post(
"/GetForPlay2",
jsonMiddleware(),
async (req: RequestWithJwt<never, GetForPlay2Body>, res) => {
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<never, GetForPlay2Body>, res) => {
if (!req.body.id || !uuidRegex.test(req.body.id)) {
res.status(400).end()
return // user sent some nasty info
@ -84,7 +85,8 @@ contractRoutingRouter.post(
req.jwt.unique_name,
req.gameVersion,
contractData,
)
) as SniperCharacter[]
const loadoutData = {
CharacterLoadoutData:
sniperloadouts.length !== 0 ? sniperloadouts : null,
@ -100,7 +102,7 @@ contractRoutingRouter.post(
req.gameVersion,
)
: {}),
...loadoutData,
...(loadoutData || {}),
...{
OpportunityData: getContractOpportunityData(req, contractData),
},
@ -120,7 +122,7 @@ contractRoutingRouter.post(
.toString()}-${randomUUID()}`,
ContractProgressionData: contractData.Metadata
.UseContractProgressionData
? await getCpd(req.jwt.unique_name, contractData.Metadata.CpdId)
? getCpd(req.jwt.unique_name, contractData.Metadata.CpdId!)
: null,
}
@ -159,6 +161,8 @@ contractRoutingRouter.post(
continue
}
assert.ok(gameChanger.Objectives, "gc has no objectives")
contractData.Data.GameChangerReferences.push(gameChanger)
contractData.Data.Bricks = [
...(contractData.Data.Bricks ?? []),
@ -228,6 +232,7 @@ contractRoutingRouter.post(
contractRoutingRouter.post(
"/CreateFromParams",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
async (
req: RequestWithJwt<Record<never, never>, CreateFromParamsBody>,
res,
@ -340,13 +345,20 @@ contractRoutingRouter.post(
contractRoutingRouter.post(
"/GetContractOpportunities",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<never, { contractId: string }>, res) => {
const contract = controller.resolveContract(req.body.contractId)
if (!contract) {
res.status(400).send("contract not found")
return
}
res.json(getContractOpportunityData(req, contract))
},
)
function getContractOpportunityData(
export function getContractOpportunityData(
req: RequestWithJwt,
contract: MissionManifest,
): MissionStory[] {
@ -366,6 +378,7 @@ function getContractOpportunityData(
missionStories[ms].PreviouslyCompleted =
ms in userData.Extensions.opportunityprogression
const current = fastClone(missionStories[ms])
// @ts-expect-error Deal with it.
delete current.Location
result.push(current)
}

View File

@ -38,6 +38,7 @@ import {
getUserEscalationProgress,
} from "./escalations/escalationService"
import { translateEntitlements } from "../ownership"
import assert from "assert"
// TODO: In the near future, this file should be cleaned up where possible.
@ -123,19 +124,28 @@ export function generateCompletionData(
let difficulty = undefined
if (gameVersion === "h1") {
difficulty = getUserData(userId, gameVersion).Extensions
.gamepersistentdata.menudata.difficulty.destinations[
subLocation ? subLocation.Properties?.ParentLocation : subLocationId
]
const userData = getUserData(userId, gameVersion)
difficulty =
userData.Extensions.gamepersistentdata.menudata.difficulty
.destinations[
subLocation
? subLocation.Properties?.ParentLocation || ""
: subLocationId
]
}
const locationId = subLocation
? subLocation.Properties?.ParentLocation
: subLocationId
assert.ok(
locationId,
`Location ID is undefined for ${subLocationId} in ${gameVersion}!`,
)
const completionData = controller.masteryService.getLocationCompletion(
locationId,
subLocation?.Id,
subLocationId,
gameVersion,
userId,
contractType,
@ -153,7 +163,7 @@ export function generateCompletionData(
Completion: 1.0,
XpLeft: 0,
Id: locationId,
SubLocationId: subLocation?.Id,
SubLocationId: subLocationId,
HideProgression: true,
IsLocationProgression: true,
Name: null,
@ -197,7 +207,7 @@ export function generateUserCentric(
// fix h1/h2 entitlements
contractData.Metadata.Entitlements = translateEntitlements(
gameVersion,
contractData.Metadata.Entitlements,
contractData.Metadata.Entitlements || [],
)
}
@ -210,6 +220,12 @@ export function generateUserCentric(
gameVersion,
)
let lastPlayed: string | undefined = undefined
if (played[id]?.LastPlayedAt) {
lastPlayed = new Date(played[id].LastPlayedAt!).toISOString()
}
const uc: UserCentricContract = {
Contract: contractData,
Data: {
@ -222,10 +238,7 @@ export function generateUserCentric(
LocationHideProgression: completionData.HideProgression,
ElusiveContractState: "",
IsFeatured: false,
LastPlayedAt:
played[id] === undefined
? undefined
: new Date(played[id]?.LastPlayedAt).toISOString(),
LastPlayedAt: lastPlayed,
// relevant for contracts
// Favorite contracts
PlaylistData: {
@ -316,6 +329,7 @@ export function mapObjectives(
gameChangerProps.ObjectivesCategory = (() => {
let obj: MissionManifestObjective
// @ts-expect-error State machines are impossible to type
for (obj of gameChangerProps.Objectives) {
if (obj.Category === "primary") return "primary"
if (obj.Category === "secondary")
@ -362,11 +376,9 @@ export function mapObjectives(
objective.OnActive.IfInProgress.Visible === false) ||
(objective.OnActive?.IfCompleted &&
objective.OnActive.IfCompleted.Visible === false &&
objective.Definition &&
objective.Definition.States &&
objective.Definition.States.Start &&
objective.Definition.States.Start["-"] &&
objective.Definition.States.Start["-"].Transition === "Success")
// @ts-expect-error State machines are impossible to type
objective.Definition?.States?.Start?.["-"]?.Transition ===
"Success")
) {
continue // do not show objectives with 'ForceShowOnLoadingScreen: false' or objectives that are not visible on start
}
@ -374,8 +386,7 @@ export function mapObjectives(
if (
objective.SuccessEvent &&
objective.SuccessEvent.EventName === "Kill" &&
objective.SuccessEvent.EventValues &&
objective.SuccessEvent.EventValues.RepositoryId
objective.SuccessEvent.EventValues?.RepositoryId
) {
result.set(objective.Id, {
Type: "kill",
@ -396,6 +407,7 @@ export function mapObjectives(
objective.Definition?.Context?.Targets &&
(objective.Definition.Context.Targets as string[]).length === 1
) {
// @ts-expect-error State machines are impossible to type
id = objective.Definition.Context.Targets[0]
}
@ -437,10 +449,8 @@ export function mapObjectives(
})
} else if (
objective.Type === "statemachine" &&
objective.Definition &&
objective.Definition.Context &&
objective.Definition.Context.Targets &&
(objective.Definition.Context.Targets as unknown[]).length === 1 &&
(objective.Definition?.Context?.Targets as unknown[])?.length ===
1 &&
objective.HUDTemplate
) {
// This objective will be displayed as a kill objective
@ -457,6 +467,7 @@ export function mapObjectives(
result.set(objective.Id, {
Type: "kill",
Properties: {
// @ts-expect-error State machines are impossible to type
Id: objective.Definition.Context.Targets[0],
Conditions: Conditions,
},

View File

@ -26,7 +26,6 @@ import type {
} from "../../types/types"
import { getUserData } from "../../databaseHandler"
import { log, LogLevel } from "../../loggingInterop"
import assert from "assert"
/**
* Put a group id in here to hide it from the menus on 2016.
@ -113,19 +112,25 @@ export function getLevelCount(
* @param userId The current user's ID.
* @param groupContractId The escalation's group contract ID.
* @param gameVersion The game's version.
* @returns The escalation play details.
* @returns The escalation play details, or an empty object if not applicable.
*/
export function getPlayEscalationInfo(
userId: string,
groupContractId: string,
groupContractId: string | undefined | null,
gameVersion: GameVersion,
): EscalationInfo {
if (!groupContractId) {
return {}
}
const userData = getUserData(userId, gameVersion)
const p = getUserEscalationProgress(userData, groupContractId)
const groupCt = controller.escalationMappings.get(groupContractId)
assert.ok(groupCt, `No escalation mapping for ${groupContractId}`)
if (!groupCt) {
return {}
}
const totalLevelCount = getLevelCount(
controller.resolveContract(groupContractId),

View File

@ -21,6 +21,7 @@ import {
ContractHistory,
GameVersion,
HitsCategoryCategory,
IHit,
} from "../types/types"
import {
contractIdToHitObject,
@ -35,6 +36,7 @@ import { log, LogLevel } from "../loggingInterop"
import { fastClone, getRemoteService } from "../utils"
import { orderedETAs } from "./elusiveTargetArcades"
import { missionsInLocations } from "./missionsInLocation"
import assert from "assert"
/**
* The filters supported for HitsCategories.
@ -154,11 +156,11 @@ export class HitsCategoryService {
switch (gameVersion) {
case "h1":
if (contract.Metadata.Season === 1)
if (contract?.Metadata.Season === 1)
contracts.push(id)
break
case "h2":
if (contract.Metadata.Season <= 2)
if ((contract?.Metadata.Season || 0) <= 2)
contracts.push(id)
break
default:
@ -232,7 +234,7 @@ export class HitsCategoryService {
.tap(tapName, (contracts, gameVersion) => {
// We need to push Peacock contracts first to work around H2 not
// having the "order" property for $arraygroupby. (The game just crashes)
const nEscalations = []
const nEscalations: string[] = []
for (const escalations of Object.values(
missionsInLocations.escalations,
@ -240,6 +242,10 @@ export class HitsCategoryService {
for (const id of escalations) {
const contract = controller.resolveContract(id)
if (!contract) {
continue
}
const isPeacock = contract.Metadata.Season === 0
const season = isPeacock
? contract.Metadata.OriginalSeason
@ -252,7 +258,7 @@ export class HitsCategoryService {
if (season === 1) contracts.push(id)
break
case "h2":
if (season <= 2)
if ((season || 0) <= 2)
(isPeacock ? contracts : nEscalations).push(
id,
)
@ -273,7 +279,7 @@ export class HitsCategoryService {
pageNumber: number,
gameVersion: GameVersion,
userId: string,
): Promise<HitsCategoryCategory> {
): Promise<HitsCategoryCategory | undefined> {
const remoteService = getRemoteService(gameVersion)
const user = userAuths.get(userId)
@ -289,10 +295,12 @@ export class HitsCategoryService {
true,
)
const hits = resp.data.data.Data.Hits
preserveContracts(
hits.map(
(hit) => hit.UserCentricContract.Contract.Metadata.PublicId,
),
void preserveContracts(
hits
.map(
(hit) => hit.UserCentricContract.Contract.Metadata.PublicId,
)
.filter(Boolean) as string[],
)
// Fix completion and favorite status for retrieved contracts
@ -304,7 +312,7 @@ export class HitsCategoryService {
if (Object.keys(played).includes(hit.Id)) {
// Replace with data stored by Peacock
hit.UserCentricContract.Data.LastPlayedAt = new Date(
played[hit.Id].LastPlayedAt,
played[hit.Id].LastPlayedAt || 0,
).toISOString()
hit.UserCentricContract.Data.Completed =
played[hit.Id].Completed
@ -314,8 +322,10 @@ export class HitsCategoryService {
hit.UserCentricContract.Data.Completed = false
}
hit.UserCentricContract.Data.PlaylistData.IsAdded =
favorites.includes(hit.Id)
if (hit.UserCentricContract.Data.PlaylistData) {
hit.UserCentricContract.Data.PlaylistData.IsAdded =
favorites.includes(hit.Id)
}
}
return resp.data.data
@ -378,13 +388,23 @@ export class HitsCategoryService {
type: ContractFilter,
category: string,
): string | undefined {
if (!this.filterSupported.includes(category)) return undefined
if (!this.filterSupported.includes(category)) {
return undefined
}
const user = getUserData(userId, gameVersion)
type Cast =
keyof typeof user.Extensions.gamepersistentdata.HitsFilterType
if (type === "default") {
type = user.Extensions.gamepersistentdata.HitsFilterType[category]
type =
user.Extensions.gamepersistentdata.HitsFilterType[
category as Cast
]
} else {
user.Extensions.gamepersistentdata.HitsFilterType[category] = type
user.Extensions.gamepersistentdata.HitsFilterType[
category as Cast
] = type
writeUserData(userId, gameVersion)
}
@ -408,7 +428,10 @@ export class HitsCategoryService {
return Object.keys(played)
.filter((id) => this.isContractOfType(played, type, id))
.sort((a, b) => {
return played[b].LastPlayedAt - played[a].LastPlayedAt
return (
(played[b].LastPlayedAt || 0) -
(played[a].LastPlayedAt || 0)
)
})
}
@ -428,9 +451,9 @@ export class HitsCategoryService {
): boolean {
switch (type) {
case "completed":
return (
return Boolean(
played[contractId]?.Completed &&
!played[contractId]?.IsEscalation
!played[contractId]?.IsEscalation,
)
case "failed":
return (
@ -440,6 +463,8 @@ export class HitsCategoryService {
)
case "all":
return !played[contractId]?.IsEscalation
default:
assert.fail("Invalid type passed to isContractOfType")
}
}
@ -457,7 +482,7 @@ export class HitsCategoryService {
pageNumber: number,
gameVersion: GameVersion,
userId: string,
): Promise<HitsCategoryCategory> {
): Promise<HitsCategoryCategory | undefined> {
if (this.realtimeFetched.includes(categoryName)) {
return await this.fetchFromOfficial(
categoryName,
@ -479,7 +504,7 @@ export class HitsCategoryService {
userId,
filter,
category,
)
)!
const hitsCategory: HitsCategoryCategory = {
Category: category,
@ -489,7 +514,7 @@ export class HitsCategoryService {
Page: pageNumber,
HasMore: false,
},
CurrentSubType: undefined,
CurrentSubType: "",
}
const hook = this.hitsCategories.for(category)
@ -500,7 +525,7 @@ export class HitsCategoryService {
const hitObjectList = hits
.map((id) => contractIdToHitObject(id, gameVersion, userId))
.filter(Boolean)
.filter(Boolean) as IHit[]
if (!this.paginationExempt.includes(category)) {
const paginated = paginate(hitObjectList, this.hitsPerPage)

View File

@ -53,9 +53,15 @@ export async function getLeaderboardEntries(
platform: JwtData["platform"],
gameVersion: GameVersion,
difficultyLevel?: string,
): Promise<GameFacingLeaderboardData> {
): Promise<GameFacingLeaderboardData | undefined> {
let difficulty = "unset"
const contract = controller.resolveContract(contractId)
if (!contract) {
return undefined
}
const parsedDifficulty = parseInt(difficultyLevel || "0")
if (parsedDifficulty === gameDifficulty.casual) {
@ -72,7 +78,7 @@ export async function getLeaderboardEntries(
const response: GameFacingLeaderboardData = {
Entries: [],
Contract: controller.resolveContract(contractId),
Contract: contract,
Page: 0,
HasMore: false,
LeaderboardType: "singleplayer",

View File

@ -1,34 +0,0 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2024 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"
import type { RequestWithJwt } from "../types/types"
const reportRouter = Router()
reportRouter.post(
"/ReportContract",
jsonMiddleware(),
(
req: RequestWithJwt<never, { contractId: string; reason: number }>,
res,
) => {
res.json({})
},
)
export { reportRouter }

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { ContractSession } from "./types/types"
import { ContractSession } from "../types/types"
/**
* Changes a set to an array.
@ -60,23 +60,28 @@ const SESSION_MAP_PROPS: (keyof ContractSession)[] = [
* @param session The ContractSession.
*/
export function serializeSession(session: ContractSession): unknown {
const o = {}
const o: Partial<ContractSession> = {}
type K = keyof ContractSession
// obj clone
for (const key of Object.keys(session)) {
if (session[key] instanceof Map) {
if (session[key as K] instanceof Map) {
// @ts-expect-error Type mismatch.
o[key] = Array.from(
(session[key] as Map<string, unknown>).entries(),
(session[key as K] as Map<string, unknown>).entries(),
)
continue
}
if (session[key] instanceof Set) {
if (session[key as K] instanceof Set) {
// @ts-expect-error Type mismatch.
o[key] = normalizeSet(session[key])
continue
}
o[key] = session[key]
// @ts-expect-error Type mismatch.
o[key] = session[key as K]
}
return o
@ -90,19 +95,22 @@ export function serializeSession(session: ContractSession): unknown {
export function deserializeSession(
saved: Record<string, unknown>,
): ContractSession {
const session = {}
const session: Partial<ContractSession> = {}
// obj clone
for (const key of Object.keys(saved)) {
// @ts-expect-error Type mismatch.
session[key] = saved[key]
}
for (const collection of SESSION_SET_PROPS) {
// @ts-expect-error Type mismatch.
session[collection] = new Set(session[collection])
}
for (const map of SESSION_MAP_PROPS) {
if (Object.hasOwn(session, map)) {
// @ts-expect-error Type mismatch.
session[map] = new Map(session[map])
}
}

View File

@ -188,8 +188,10 @@ function createPeacockRequire(pluginName: string): NodeRequire {
* @param specifier The requested module.
*/
const peacockRequire: NodeRequire = (specifier: string) => {
if (generatedPeacockRequireTable[specifier]) {
return generatedPeacockRequireTable[specifier]
type T = keyof typeof generatedPeacockRequireTable
if (generatedPeacockRequireTable[specifier as T]) {
return generatedPeacockRequireTable[specifier as T]
}
try {
@ -237,14 +239,22 @@ export const validateMission = (m: MissionManifest): boolean => {
return false
}
for (const prop of ["Id", "Title", "Location", "ScenePath"]) {
if (!Object.hasOwn(m.Metadata, prop)) {
for (const prop of <(keyof MissionManifest["Metadata"])[]>[
"Id",
"Title",
"Location",
"ScenePath",
]) {
if (!m.Metadata[prop]) {
log(LogLevel.ERROR, `Contract missing property Metadata.${prop}!`)
return false
}
}
for (const prop of ["Objectives", "Bricks"]) {
for (const prop of <(keyof MissionManifest["Data"])[]>[
"Objectives",
"Bricks",
]) {
if (!Object.hasOwn(m.Data, prop)) {
log(LogLevel.ERROR, `Contract missing property Data.${prop}!`)
return false
@ -365,11 +375,11 @@ export class Controller {
*/
public fetchedContracts: Map<string, MissionManifest> = new Map()
public challengeService: ChallengeService
public masteryService: MasteryService
public challengeService!: ChallengeService
public masteryService!: MasteryService
escalationMappings: Map<string, Record<string, string>> = new Map()
public progressionService: ProgressionService
public smf: SMFSupport
public progressionService!: ProgressionService
public smf!: SMFSupport
private _pubIdToContractId: Map<string, string> = new Map()
/** Internal elusive target contracts - only accessible during bootstrap. */
private _internalElusives: MissionManifest[] | undefined
@ -444,12 +454,8 @@ export class Controller {
this.hooks.challengesLoaded.call()
this.hooks.masteryDataLoaded.call()
} catch (e) {
log(
LogLevel.ERROR,
`Fatal error with challenge bootstrap: ${e}`,
"boot",
)
log(LogLevel.ERROR, e.stack)
log(LogLevel.ERROR, `Fatal error with challenge bootstrap`, "boot")
log(LogLevel.ERROR, e)
}
}
@ -461,6 +467,11 @@ export class Controller {
continue
}
assert.ok(
contract.Metadata.GroupDefinition,
"arcade contract has no group definition",
)
for (const lId of contract.Metadata.GroupDefinition.Order) {
const level = this.resolveContract(lId, false)
@ -481,9 +492,10 @@ export class Controller {
)
for (const location of this.locationsWithETA) {
this.parentsWithETA.add(
locations.children[location].Properties.ParentLocation,
)
const pl = locations.children[location].Properties.ParentLocation
assert.ok(pl, "no parent location")
this.parentsWithETA.add(pl)
}
}
@ -509,14 +521,7 @@ export class Controller {
)
}
if (this.contracts.has(this._pubIdToContractId.get(pubId)!)) {
return (
this.contracts.get(this._pubIdToContractId.get(pubId)!) ||
undefined
)
}
return undefined
return this.contracts.get(this._pubIdToContractId.get(pubId)!)
}
/**
@ -546,6 +551,10 @@ export class Controller {
private getGroupContract(json: MissionManifest) {
if (escalationTypes.includes(json.Metadata.Type)) {
if (!json.Metadata.InGroup) {
return json
}
return this.resolveContract(json.Metadata.InGroup) ?? json
}
@ -565,7 +574,7 @@ export class Controller {
* @returns The mission manifest object, or undefined if it wasn't found.
*/
public resolveContract(
id: string,
id: string | undefined,
getGroup = false,
): MissionManifest | undefined {
if (!id) {
@ -650,11 +659,16 @@ export class Controller {
this.addMission(groupContract)
fixedLevels.forEach((level) => this.addMission(level))
this.missionsInLocations.escalations[locationId] ??= []
type K = keyof typeof this.missionsInLocations.escalations
this.missionsInLocations.escalations[locationId].push(
groupContract.Metadata.Id,
)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.missionsInLocations.escalations[locationId as K] ??= <any>[]
const a = this.missionsInLocations.escalations[
locationId as K
] as string[]
a.push(groupContract.Metadata.Id)
this.scanForGroups()
}
@ -758,7 +772,6 @@ export class Controller {
}
} catch (e) {
log(LogLevel.ERROR, `Failed to load contract ${i}!`)
log(LogLevel.ERROR, e.stack)
}
})
}
@ -1028,7 +1041,6 @@ export class Controller {
let theExports
try {
// eslint-disable-next-line prefer-const
theExports = new Script(pluginContents, {
filename: pluginPath,
}).runInContext(context)
@ -1038,7 +1050,6 @@ export class Controller {
`Error while attempting to queue plugin ${pluginName} for loading!`,
)
log(LogLevel.ERROR, e)
log(LogLevel.ERROR, e.stack)
return
}
@ -1057,7 +1068,6 @@ export class Controller {
} catch (e) {
log(LogLevel.ERROR, `Error while evaluating plugin ${pluginName}!`)
log(LogLevel.ERROR, e)
log(LogLevel.ERROR, e.stack)
}
}
@ -1185,7 +1195,7 @@ export function contractIdToHitObject(
"LocationsData",
gameVersion,
false,
).parents[subLocation?.Properties?.ParentLocation]
).parents[subLocation?.Properties?.ParentLocation || ""]
// failed to find the location, must be from a newer game
if (!subLocation && ["h1", "h2", "scpc"].includes(gameVersion)) {

View File

@ -19,7 +19,7 @@
import { readFile, writeFile } from "atomically"
import { join } from "path"
import type { ContractSession, GameVersion, UserProfile } from "./types/types"
import { serializeSession, deserializeSession } from "./sessionSerialization"
import { serializeSession, deserializeSession } from "./contracts/sessions"
import { castUserProfile } from "./utils"
import { log, LogLevel } from "./loggingInterop"
import { unlink, readdir } from "fs/promises"

View File

@ -16,6 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Vendor code - does not need type-checking.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import EventEmitter from "events"
import { clearTimeout, setTimeout } from "timers"
import { IPCTransport } from "./ipc"

View File

@ -16,6 +16,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Vendor code - does not need type-checking.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import net from "net"
import EventEmitter from "events"
import axios from "axios"

View File

@ -67,7 +67,7 @@ export class IOIStrategy extends EntitlementStrategy {
constructor(gameVersion: GameVersion, private readonly issuerId: string) {
super()
this.issuerId = issuerId
this._remoteService = getRemoteService(gameVersion)
this._remoteService = getRemoteService(gameVersion)!
}
override async get(userId: string) {

View File

@ -174,8 +174,8 @@ export function setupScoring(
if (name === "scoring") {
const definition: ManifestScoringDefinition = deepmerge(
...module.ScoringDefinitions,
)
...(module.ScoringDefinitions || []),
) as unknown as ManifestScoringDefinition
let state = "Start"
let context = definition.Context
@ -200,15 +200,21 @@ export function setupScoring(
context = immediate.context
}
// @ts-expect-error Type issue
scoring.Definition = definition
// @ts-expect-error Type issue
scoring.Context = context
// @ts-expect-error Type issue
scoring.State = state
} else {
// @ts-expect-error Type issue
scoring.Settings[name] = module
// @ts-expect-error Type issue
delete scoring.Settings[name]["Type"]
}
}
// @ts-expect-error Type issue
session.scoring = scoring
}
@ -373,13 +379,13 @@ export function newSession(
}
export type SSE3Response = {
SavedTokens: string[]
NewEvents: ServerToClientEvent[]
SavedTokens: string[] | null
NewEvents: ServerToClientEvent[] | null
NextPoll: number
}
export type SSE4Response = SSE3Response & {
PushMessages: string[]
PushMessages: string[] | null
}
export function saveAndSyncEvents(
@ -410,7 +416,7 @@ export function saveAndSyncEvents(
let pushMessages: string[] | undefined
if ((userPushQueue = pushMessageQueue.get(userId))) {
userPushQueue = userPushQueue.filter((item) => item.time > lastPushDt)
userPushQueue = userPushQueue.filter((item) => item.time > lastPushDt!)
pushMessageQueue.set(userId, userPushQueue)
pushMessages = Array.from(userPushQueue, (item) => item.message)
@ -430,20 +436,21 @@ export function saveAndSyncEvents(
}
}
type SSE3Body = {
lastEventTicks: number | string
userId: string
values: ClientToServerEvent[]
}
type SSE4Body = SSE3Body & {
lastPushDt: number | string
}
eventRouter.post(
"/SaveAndSynchronizeEvents3",
jsonMiddleware({ limit: "10Mb" }),
(
req: RequestWithJwt<
unknown,
{
lastEventTicks: number | string
userId: string
values: ClientToServerEvent[]
}
>,
res,
) => {
// @ts-expect-error Request has jwt props.
(req: RequestWithJwt<unknown, SSE3Body>, res) => {
if (req.body.userId !== req.jwt.unique_name) {
res.status(403).send() // Trying to save events for other user
return
@ -469,18 +476,8 @@ eventRouter.post(
eventRouter.post(
"/SaveAndSynchronizeEvents4",
jsonMiddleware({ limit: "10Mb" }),
(
req: RequestWithJwt<
unknown,
{
lastPushDt: number | string
lastEventTicks: number | string
userId: string
values: ClientToServerEvent[]
}
>,
res,
) => {
// @ts-expect-error Request has jwt props.
(req: RequestWithJwt<unknown, SSE4Body>, res) => {
if (req.body.userId !== req.jwt.unique_name) {
res.status(403).send() // Trying to save events for other user
return
@ -507,6 +504,7 @@ eventRouter.post(
eventRouter.post(
"/SaveEvents2",
jsonMiddleware({ limit: "10Mb" }),
// @ts-expect-error Request has jwt props.
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.userId) {
res.status(403).send() // Trying to save events for other user
@ -595,7 +593,7 @@ function contractFailed(
) {
if (session.completedObjectives.size === 0) break arcadeFail
for (const obj of json.Data.Objectives) {
for (const obj of json.Data.Objectives || []) {
if (
session.completedObjectives.has(obj.Id) &&
obj.Category === "primary"
@ -707,7 +705,9 @@ function saveEvents(
const val = handleEvent(
objectiveDefinition as never,
objectiveContext,
// SMP sucks. Sorry, not sorry.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
objectiveContext as any,
event.Value,
{
eventName: event.Name,
@ -734,7 +734,6 @@ function saveEvents(
"An error occurred while tracing C2S events, please report this!",
)
log(LogLevel.ERROR, e)
log(LogLevel.ERROR, e.stack)
}
}
@ -744,7 +743,9 @@ function saveEvents(
const val = handleEvent(
session.scoring.Definition as never,
scoringContext,
// SMP sucks. Sorry, not sorry.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
scoringContext as any,
event.Value,
{
eventName: event.Name,
@ -762,7 +763,10 @@ function saveEvents(
controller.challengeService.onContractEvent(event, session)
if (event.Name.startsWith("ScoringScreenEndState_")) {
if (
event.Name.startsWith("ScoringScreenEndState_") &&
session.evergreen
) {
session.evergreen.scoringScreenEndState = event.Name
processed.push(event.Name)
@ -1010,7 +1014,10 @@ function saveEvents(
const areaId = (<AreaDiscoveredC2SEvent>event).Value
.RepositoryId
const challengeId = getConfig("AreaMap", false)[areaId]
const challengeId = getConfig<Record<string, string>>(
"AreaMap",
false,
)[areaId]
const progress = userData.Extensions.ChallengeProgression
log(LogLevel.DEBUG, `Area discovered: ${areaId}`)
@ -1044,16 +1051,22 @@ function saveEvents(
break
// Evergreen
case "CpdSet":
setCpd(
event.Value as ContractProgressionData,
userId,
contract.Metadata.CpdId,
)
if (contract?.Metadata.CpdId) {
setCpd(
event.Value as ContractProgressionData,
userId,
contract.Metadata.CpdId,
)
}
break
case "Evergreen_Payout_Data":
session.evergreen.payout = (<Evergreen_Payout_DataC2SEvent>(
event
)).Value.Total_Payout
if (session.evergreen) {
session.evergreen.payout = (<Evergreen_Payout_DataC2SEvent>(
event
)).Value.Total_Payout
}
break
case "MissionFailed_Event":
if (session.evergreen) {

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs"
import { existsSync, readFileSync, writeFileSync } from "fs"
import type { Flags } from "./types/types"
import { log, LogLevel } from "./loggingInterop"
import { parse } from "js-ini"
@ -123,7 +123,6 @@ const defaultFlags: Flags = {
},
}
const OLD_FLAGS_FILE = "flags.json5"
const NEW_FLAGS_FILE = "options.ini"
/**
@ -152,7 +151,10 @@ const makeFlagsIni = (
Object.keys(defaultFlags)
.map((flagId) => {
return `; ${defaultFlags[flagId].desc}
${flagId} = ${_flags[flagId]}`
${flagId} = ${
// @ts-expect-error You know what, I don't care
_flags[flagId]
}`
})
.join("\n\n")
@ -160,24 +162,11 @@ ${flagId} = ${_flags[flagId]}`
* Loads all flags.
*/
export function loadFlags(): void {
// somebody please, clean this method up, I hate it
if (existsSync(OLD_FLAGS_FILE)) {
log(
LogLevel.WARN,
"The flags file (flags.json5) has been revamped in the latest Peacock version, and we had to remove your settings.",
)
log(
LogLevel.INFO,
"You can take a look at the new options.ini file, which includes descriptions and more!",
)
unlinkSync(OLD_FLAGS_FILE)
}
if (!existsSync(NEW_FLAGS_FILE)) {
const allTheFlags = {}
Object.keys(defaultFlags).forEach((f) => {
// @ts-expect-error You know what, I don't care
allTheFlags[f] = defaultFlags[f].default
})

View File

@ -37,13 +37,11 @@ import * as platformEntitlements from "./platformEntitlements"
import * as playStyles from "./playStyles"
import * as profileHandler from "./profileHandler"
import * as scoreHandler from "./scoreHandler"
import * as sessionSerialization from "./sessionSerialization"
import * as smfSupport from "./smfSupport"
import * as utils from "./utils"
import * as webFeatures from "./webFeatures"
import * as legacyContractHandler from "./2016/legacyContractHandler"
import * as legacyMenuData from "./2016/legacyMenuData"
import * as legacyMenuSystem from "./2016/legacyMenuSystem"
import * as legacyProfileRouter from "./2016/legacyProfileRouter"
import * as challengeHelpers from "./candle/challengeHelpers"
import * as challengeService from "./candle/challengeService"
@ -57,7 +55,7 @@ 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 sessions from "./contracts/sessions"
import * as client from "./discord/client"
import * as ipc from "./discord/ipc"
import * as liveSplitClient from "./livesplit/liveSplitClient"
@ -69,6 +67,7 @@ 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 playerProfile from "./menus/playerProfile"
import * as playnext from "./menus/playnext"
import * as sniper from "./menus/sniper"
import * as stashpoints from "./menus/stashpoints"
@ -125,10 +124,6 @@ export default {
...profileHandler,
},
"@peacockproject/core/scoreHandler": { __esModule: true, ...scoreHandler },
"@peacockproject/core/sessionSerialization": {
__esModule: true,
...sessionSerialization,
},
"@peacockproject/core/smfSupport": { __esModule: true, ...smfSupport },
"@peacockproject/core/utils": { __esModule: true, ...utils },
"@peacockproject/core/webFeatures": { __esModule: true, ...webFeatures },
@ -140,10 +135,6 @@ export default {
__esModule: true,
...legacyMenuData,
},
"@peacockproject/core/2016/legacyMenuSystem": {
__esModule: true,
...legacyMenuSystem,
},
"@peacockproject/core/2016/legacyProfileRouter": {
__esModule: true,
...legacyProfileRouter,
@ -193,9 +184,9 @@ export default {
__esModule: true,
...missionsInLocation,
},
"@peacockproject/core/contracts/reportRouting": {
"@peacockproject/core/contracts/sessions": {
__esModule: true,
...reportRouting,
...sessions,
},
"@peacockproject/core/discord/client": { __esModule: true, ...client },
"@peacockproject/core/discord/ipc": { __esModule: true, ...ipc },
@ -226,6 +217,10 @@ export default {
...menuSystem,
},
"@peacockproject/core/menus/planning": { __esModule: true, ...planning },
"@peacockproject/core/menus/playerProfile": {
__esModule: true,
...playerProfile,
},
"@peacockproject/core/menus/playnext": { __esModule: true, ...playnext },
"@peacockproject/core/menus/sniper": { __esModule: true, ...sniper },
"@peacockproject/core/menus/stashpoints": {

View File

@ -89,7 +89,7 @@ export interface Intercept<Params, Return> {
* @param context The context object. Can be modified.
* @param params The parameters that the taps will get. Can be modified.
*/
call(context, ...params: AsArray<Params>): void
call(context: unknown, ...params: AsArray<Params>): void | Promise<void>
/**
* A function called when the hook is tapped. Note that it will not be called when an interceptor is registered, since that doesn't count as a tap.
@ -108,8 +108,8 @@ export interface Intercept<Params, Return> {
* @see AsyncSeriesHook
*/
export abstract class BaseImpl<Params, Return = void> {
protected _intercepts: Intercept<Params, Return>[]
protected _taps: Tap<Params, Return>[]
protected _intercepts!: Intercept<Params, Return>[]
protected _taps!: Tap<Params, Return>[]
/**
* Register an interceptor.

View File

@ -16,8 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// noinspection RequiredAttributes
// load as soon as possible to prevent dependency issues
import "./generatedPeacockRequireTable"
@ -26,7 +24,6 @@ import { getFlag, loadFlags } from "./flags"
loadFlags()
import { setFlagsFromString } from "v8"
import { program } from "commander"
import express, { Request, Router } from "express"
import http from "http"
@ -41,7 +38,12 @@ import {
ServerVer,
} from "./utils"
import { getConfig } from "./configSwizzleManager"
import { handleOauthToken } from "./oauthToken"
import {
error400,
error406,
handleOAuthToken,
OAuthTokenBody,
} from "./oauthToken"
import type {
RequestWithJwt,
S2CEventWithTimestamp,
@ -61,7 +63,6 @@ import { contractRoutingRouter } from "./contracts/contractRouting"
import { profileRouter } from "./profileHandler"
import { menuDataRouter } from "./menuData"
import { menuSystemPreRouter, menuSystemRouter } from "./menus/menuSystem"
import { legacyMenuSystemRouter } from "./2016/legacyMenuSystem"
import { _theLastYardbirdScpc, controller } from "./controller"
import {
STEAM_NAMESPACE_2016,
@ -88,10 +89,8 @@ import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData"
import { pack, unpack } from "msgpackr"
import { liveSplitManager } from "./livesplit/liveSplitManager"
import { cheapLoadUserData } from "./databaseHandler"
import { reportRouter } from "./contracts/reportRouting"
// welcome to the bleeding edge
setFlagsFromString("--harmony")
loadFlags()
const host = process.env.HOST || "0.0.0.0"
const port = process.env.PORT || 80
@ -145,12 +144,13 @@ app.get("/", (_: Request, res) => {
res.send(
'<html lang="en">PEACOCK_DEV active, please run "yarn webui start" to start the web UI on port 3000 and access it there.</html>',
)
} else {
const data = readFileSync("webui/dist/index.html").toString()
res.contentType("text/html")
res.send(data)
return
}
const data = readFileSync("webui/dist/index.html").toString()
res.contentType("text/html")
res.send(data)
})
serveStatic.mime.define({ "application/javascript": ["js"] })
@ -169,6 +169,7 @@ if (getFlag("loadoutSaving") === "PROFILES") {
app.get(
"/config/:audience/:serverVersion(\\d+_\\d+_\\d+)",
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<{ issuer: string }>, res) => {
const proto = req.protocol
const config = getConfig(
@ -177,11 +178,13 @@ app.get(
) as ServerConnectionConfig
const serverhost = req.get("Host")
config.Versions[0].GAME_VER = req.params.serverVersion.startsWith("8")
? `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}`
: req.params.serverVersion.startsWith("7")
? "7.17.0"
: "6.74.0"
config.Versions[0].GAME_VER = "6.74.0"
if (req.params.serverVersion.startsWith("8")) {
req.params.serverVersion = `${ServerVer._Major}.${ServerVer._Minor}.${ServerVer._Build}`
} else if (req.params.serverVersion.startsWith("7")) {
req.params.serverVersion = "7.17.0"
}
if (req.params.serverVersion.startsWith("8")) {
config.Versions[0].SERVER_VER.GlobalAuthentication.RequestedAudience =
@ -229,7 +232,7 @@ app.get(
},
)
app.get("/files/privacypolicy/hm3/privacypolicy_*.json", (req, res) => {
app.get("/files/privacypolicy/hm3/privacypolicy_*.json", (_, res) => {
res.set("Content-Type", "application/octet-stream")
res.set("x-ms-meta-version", "20181001")
res.send(getConfig("PrivacyPolicy", false))
@ -238,6 +241,7 @@ app.get("/files/privacypolicy/hm3/privacypolicy_*.json", (req, res) => {
app.post(
"/api/metrics/*",
jsonMiddleware({ limit: "10Mb" }),
// @ts-expect-error jwt props.
(req: RequestWithJwt<never, S2CEventWithTimestamp[]>, res) => {
for (const event of req.body) {
controller.hooks.newMetricsEvent.call(event, req)
@ -247,8 +251,26 @@ app.post(
},
)
app.post("/oauth/token", urlencoded(), (req: RequestWithJwt, res) =>
handleOauthToken(req, res),
app.post(
"/oauth/token",
urlencoded(),
// @ts-expect-error jwt props.
(req: RequestWithJwt<never, OAuthTokenBody>, res) => {
handleOAuthToken(req)
.then((token) => {
if (token === error400) {
return res.status(400).send()
} else if (token === error406) {
return res.status(406).send()
} else {
return res.json(token)
}
})
.catch((err) => {
log(LogLevel.ERROR, err.message)
res.status(500).send()
})
},
)
app.get("/files/onlineconfig.json", (_, res) => {
@ -263,15 +285,16 @@ app.use(
Router()
.use(
"/resources-:serverVersion(\\d+-\\d+)/",
(req: RequestWithJwt, res, next) => {
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, _, next) => {
req.serverVersion = req.params.serverVersion
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7")
? // prettier-ignore
"h2"
: // prettier-ignore
"h1"
req.gameVersion = "h1"
if (req.serverVersion.startsWith("8")) {
req.gameVersion = "h3"
} else if (req.serverVersion.startsWith("7")) {
req.gameVersion = "h2"
}
if (req.serverVersion === "7.3.0") {
req.gameVersion = "scpc"
@ -281,6 +304,7 @@ app.use(
},
)
// we're fine with skipping to the next router if we don't have auth
// @ts-expect-error Has jwt props.
.use(extractToken, (req: RequestWithJwt, res, next) => {
switch (req.jwt?.pis) {
case "egp_io_interactive_hitman_the_complete_first_season":
@ -300,11 +324,13 @@ app.use(
return
}
req.gameVersion = req.serverVersion.startsWith("8")
? "h3"
: req.serverVersion.startsWith("7")
? "h2"
: "h1"
req.gameVersion = "h1"
if (req.serverVersion.startsWith("8")) {
req.gameVersion = "h3"
} else if (req.serverVersion.startsWith("7")) {
req.gameVersion = "h2"
}
if (req.jwt?.aud === "scpc-prod") {
req.gameVersion = "scpc"
@ -316,6 +342,7 @@ app.use(
app.get(
"/profiles/page//dashboard//Dashboard_Category_Sniper_Singleplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d",
// @ts-expect-error jwt props.
(req: RequestWithJwt, res) => {
res.json({
template: getConfig("FrankensteinMmSpTemplate", false),
@ -339,6 +366,7 @@ app.get(
// We handle this for now, but it's not used. For the future though.
app.get(
"/profiles/page//dashboard//Dashboard_Category_Sniper_Multiplayer/00000000-0000-0000-0000-000000000015/Contract/ff9f46cf-00bd-4c12-b887-eac491c3a96d",
// @ts-expect-error jwt props.
(req: RequestWithJwt, res) => {
const template = getConfig("FrankensteinMmMpTemplate", false)
@ -371,6 +399,7 @@ app.get(
)
if (PEACOCK_DEV) {
// @ts-expect-error Has jwt props.
app.use(async (req: RequestWithJwt, _res, next): Promise<void> => {
if (!req.jwt) {
next()
@ -397,6 +426,7 @@ function generateBlobConfig(req: RequestWithJwt) {
app.get(
"/authentication/api/configuration/Init?*",
// @ts-expect-error jwt props.
extractToken,
(req: RequestWithJwt, res) => {
// configName=pc-prod&lockedContentDisabled=false&isFreePrologueUser=false&isIntroPackUser=false&isFullExperienceUser=false
@ -413,6 +443,7 @@ app.get(
app.post(
"/authentication/api/userchannel/AuthenticationService/RenewBlobSignature",
// @ts-expect-error jwt props.
(req: RequestWithJwt, res) => {
res.json(generateBlobConfig(req))
},
@ -421,7 +452,6 @@ app.post(
const legacyRouter = Router()
const primaryRouter = Router()
legacyRouter.use("/resources-(\\d+-\\d+)/", legacyMenuSystemRouter)
legacyRouter.use("/authentication/api/userchannel/", legacyProfileRouter)
legacyRouter.use("/profiles/page/", legacyMenuDataRouter)
legacyRouter.use(
@ -442,9 +472,12 @@ primaryRouter.use(
"/authentication/api/userchannel/ContractsService/",
contractRoutingRouter,
)
primaryRouter.use(
"/authentication/api/userchannel/ReportingService/",
reportRouter,
primaryRouter.get(
"/authentication/api/userchannel/ReportingService/ReportContract",
(_, res) => {
// TODO
res.json({})
},
)
primaryRouter.use("/authentication/api/userchannel/", profileRouter)
primaryRouter.use("/profiles/page", multiplayerMenuDataRouter)
@ -454,6 +487,7 @@ primaryRouter.use("/resources-(\\d+-\\d+)/", menuSystemRouter)
app.use(
Router()
// @ts-expect-error Has jwt props.
.use((req: RequestWithJwt, _, next) => {
if (req.shouldCease) {
return next("router")
@ -467,6 +501,7 @@ app.use(
})
.use(legacyRouter),
Router()
// @ts-expect-error Has jwt props.
.use((req: RequestWithJwt, _, next) => {
if (req.shouldCease) {
return next("router")
@ -493,28 +528,15 @@ app.all("*", (req, res) => {
app.use(errorLoggingMiddleware)
program.description(
"The Peacock Project is a HITMAN™ World of Assassination Trilogy server built for general use.",
"The Peacock Project is a HITMAN™ World of Assassination Trilogy server replacement.",
)
const PEECOCK_ART = picocolors.yellow(`
`)
function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void {
checkForUpdates()
void checkForUpdates()
if (!IS_LAUNCHER) {
console.log(
Math.random() < 0.001
? PEECOCK_ART
: picocolors.greenBright(`
picocolors.greenBright(`
@ -576,7 +598,7 @@ function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void {
if (options.hmr) {
log(LogLevel.DEBUG, "Experimental HMR enabled.")
setupHotListener("contracts", () => {
void setupHotListener("contracts", () => {
log(LogLevel.INFO, "Detected a change in contracts! Re-indexing...")
controller.index()
})
@ -584,7 +606,7 @@ function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void {
// once contracts directory is present, we are clear to boot
loadouts.init()
controller.boot(options.pluginDevHost)
void controller.boot(options.pluginDevHost)
const httpServer = http.createServer(app)
@ -597,7 +619,7 @@ function startServer(options: { hmr: boolean; pluginDevHost: boolean }): void {
}
// initialize livesplit
liveSplitManager.init()
void liveSplitManager.init()
}
program.option(
@ -616,9 +638,10 @@ program
.command("tools")
.description("open the tools UI")
.action(() => {
toolsMenu()
void toolsMenu()
})
// noinspection RequiredAttributes
program
.command("pack")
.argument("<input>", "input file to pack")
@ -636,6 +659,7 @@ program
log(LogLevel.INFO, `Packed "${input}" to "${outputPath}" successfully.`)
})
// noinspection RequiredAttributes
program
.command("unpack")
.argument("<input>", "input file to unpack")

View File

@ -106,7 +106,7 @@ export function clearInventoryCache(): void {
function filterUnlockedContent(
userProfile: UserProfile,
packagedUnlocks: Map<string, boolean>,
challengesUnlockables: object,
challengesUnlockables: Record<string, string>,
gameVersion: GameVersion,
) {
return function (
@ -114,7 +114,7 @@ function filterUnlockedContent(
unlockable: Unlockable,
) {
let unlockableChallengeId: string
let unlockableMasteryData: UnlockableMasteryData
let unlockableMasteryData: UnlockableMasteryData | undefined
// Handles unlockables that belong to a package or unlocked gear from evergreen
if (packagedUnlocks.has(unlockable.Id)) {
@ -123,7 +123,7 @@ function filterUnlockedContent(
// Handles packages
else if (unlockable.Type === "package") {
for (const pkgUnlockableId of unlockable.Properties.Unlocks) {
for (const pkgUnlockableId of unlockable.Properties.Unlocks || []) {
packagedUnlocks.set(pkgUnlockableId, true)
}
@ -199,7 +199,7 @@ function filterUnlockedContent(
* @returns boolean
*/
function filterAllowedContent(gameVersion: GameVersion, entP: string[]) {
return function (unlockContainer: {
return function (unlockContainer?: {
InstanceId: string
ProfileId: string
Unlockable: Unlockable
@ -474,21 +474,25 @@ function updateWithDefaultSuit(
profileId: string,
gameVersion: GameVersion,
inv: InventoryItem[],
sublocation: Unlockable,
sublocation?: Unlockable,
): InventoryItem[] {
if (sublocation === undefined) {
if (!sublocation) {
return inv
}
// We need to add a suit, so need to copy the cache to prevent modifying it.
const newInv = [...inv]
// Yes this is slow. We should organize the unlockables into a { [Id: string]: Unlockable } map.
const locationSuit = getUnlockableById(
getDefaultSuitFor(sublocation),
gameVersion,
)
if (!locationSuit) {
return inv
}
// We need to add a suit, so need to copy the cache to prevent modifying it.
const newInv = [...inv]
// check if any inventoryItem's unlockable is the default suit for the sublocation
if (newInv.every((i) => i.Unlockable.Id !== locationSuit.Id)) {
// if not, add it
@ -576,7 +580,7 @@ export function createInventory(
// and location-wide default suits will be given afterwards.
const defaults = Object.values(defaultSuits)
if ((getFlag("getDefaultSuits") as boolean) === false) {
if (!getFlag("getDefaultSuits")) {
unlockables = unlockables.filter(
(u) =>
!defaults.includes(u.Id) ||
@ -584,8 +588,7 @@ export function createInventory(
)
}
// ts-expect-error It cannot be undefined.
const filtered: InventoryItem[] = unlockables
const filtered = unlockables
.map((unlockable) => {
if (brokenItems.includes(unlockable.Guid)) {
return undefined
@ -601,7 +604,9 @@ export function createInventory(
}
})
// filter again, this time removing legacy unlockables
.filter(filterAllowedContent(gameVersion, userProfile.Extensions.entP))
.filter(
filterAllowedContent(gameVersion, userProfile.Extensions.entP),
) as InventoryItem[]
for (const unlockable of filtered) {
unlockable!.ProfileId = profileId
@ -627,7 +632,7 @@ export function grantDrops(profileId: string, drops: Unlockable[]): void {
inventoryUserCache.set(profileId, [
...new Set([
...inventoryUserCache.get(profileId),
...(inventoryUserCache.get(profileId) || []),
...inventoryItems.filter(
(invItem) => invItem.Unlockable.Type !== "evergreenmastery",
),

View File

@ -50,14 +50,7 @@ const defaultValue: LoadoutFile = {
* A class for managing loadouts.
*/
export class Loadouts {
private _loadouts: LoadoutFile
/**
* Creates a new instance of the class.
*/
public constructor() {
this._loadouts = undefined
}
private _loadouts!: LoadoutFile
/**
* Get the loadouts data.
@ -107,7 +100,10 @@ export class Loadouts {
// if the selected value is null/undefined or is not length 0 or 21, it's not a valid id
if (
!this._loadouts[gameVersion].selected ||
![0, 21].includes(this._loadouts[gameVersion].selected.length)
// first condition ensures selected is truthy, but TS doesn't know
![0, 21].includes(
this._loadouts[gameVersion].selected?.length || -1,
)
) {
dirty = true
@ -121,7 +117,7 @@ export class Loadouts {
}
}
if (dirty === true) {
if (dirty) {
writeFileSync(LOADOUT_PROFILES_FILE, JSON.stringify(this._loadouts))
}
}
@ -217,7 +213,7 @@ loadoutRouter.patch(
async (
req: Request<
never,
string,
string | { error?: string; message?: string },
{ gameVersion: "h1" | "h2" | "h3"; id: string }
>,
res,

View File

@ -16,8 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { NextFunction, Response } from "express"
import type { RequestWithJwt } from "./types/types"
import type { NextFunction, Request, Response } from "express"
import picocolors from "picocolors"
import winston from "winston"
import "winston-daily-rotate-file"
@ -135,6 +134,7 @@ if (consoleLogLevel !== LOG_LEVEL_NONE) {
}
const winstonLogLevel = {}
// @ts-expect-error Type mismatch.
Object.values(LogLevel).forEach((e, i) => (winstonLogLevel[e] = i))
const logger = winston.createLogger({
@ -255,13 +255,13 @@ export function log(
* Express middleware that logs all requests and their details with the info log level.
*
* @param req The Express request object.
* @param res The Express response object.
* @param _ The Express response object.
* @param next The Express next function.
* @see LogLevel.INFO
*/
export function loggingMiddleware(
req: RequestWithJwt,
res: Response,
req: Request,
_: Response,
next?: NextFunction,
): void {
log(
@ -273,7 +273,7 @@ export function loggingMiddleware(
}
export function requestLoggingMiddleware(
req: RequestWithJwt,
req: Request,
res: Response,
next?: NextFunction,
): void {
@ -294,8 +294,8 @@ export function requestLoggingMiddleware(
export function errorLoggingMiddleware(
err: Error,
req: RequestWithJwt,
res: Response,
req: Request,
_: Response,
next?: NextFunction,
): void {
const debug = {

View File

@ -21,7 +21,6 @@ import { Response, Router } from "express"
import {
contractCreationTutorialId,
getMaxProfileLevel,
isSniperLocation,
isSuit,
unlockOrderComparer,
uuidRegex,
@ -29,22 +28,15 @@ import {
import { contractSessions, getSession } from "./eventHandler"
import { getConfig, getVersionedConfig } from "./configSwizzleManager"
import { controller } from "./controller"
import {
createLocationsData,
getDestination,
getDestinationCompletion,
} from "./menus/destinations"
import { createLocationsData, getDestination } from "./menus/destinations"
import type {
ChallengeCategoryCompletion,
SelectEntranceOrPickupData,
ContractSearchResult,
GameVersion,
HitsCategoryCategory,
PeacockLocationsData,
PlayerProfileView,
ProgressionData,
RequestWithJwt,
SceneConfig,
SelectEntranceOrPickupData,
UserCentricContract,
} from "./types/types"
import {
@ -79,7 +71,9 @@ import {
DebriefingLeaderboardsQuery,
GetCompletionDataForLocationQuery,
GetDestinationQuery,
GetMasteryCompletionDataForUnlockableQuery,
LeaderboardEntriesCommonQuery,
LookupContractPublicIdQuery,
MasteryUnlockableQuery,
MissionEndRequestQuery,
PlanningQuery,
@ -97,6 +91,7 @@ import {
getSafehouseCategory,
} from "./menus/stashpoints"
import { getHubData } from "./menus/hub"
import { getPlayerProfileData } from "./menus/playerProfile"
const menuDataRouter = Router()
@ -104,18 +99,19 @@ const menuDataRouter = Router()
menuDataRouter.get(
"/ChallengeLocation",
// @ts-expect-error Jwt props.
(req: RequestWithJwt<ChallengeLocationQuery>, res) => {
if (typeof req.query.locationId !== "string") {
res.status(400).send("Invalid locationId")
return
}
const location = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
req.gameVersion,
true,
).children[req.query.locationId]
if (!location) {
res.status(400).send("Invalid locationId")
return
}
const data = {
Name: location.DisplayNameLocKey,
Location: location,
@ -137,17 +133,20 @@ menuDataRouter.get(
},
)
// @ts-expect-error Jwt props.
menuDataRouter.get("/Hub", (req: RequestWithJwt, res) => {
const hubInfo = getHubData(req.gameVersion, req.jwt)
const hubInfo = getHubData(req.gameVersion, req.jwt.unique_name)
const template =
req.gameVersion === "h3"
? null
: req.gameVersion === "h2"
? null
: req.gameVersion === "scpc"
? getConfig("FrankensteinHubTemplate", false)
: getConfig("LegacyHubTemplate", false)
let template: unknown
if (req.gameVersion === "h3" || req.gameVersion === "h2") {
template = null
} else {
template =
req.gameVersion === "scpc"
? getConfig("FrankensteinHubTemplate", false)
: getConfig("LegacyHubTemplate", false)
}
res.json({
template,
@ -155,6 +154,7 @@ menuDataRouter.get("/Hub", (req: RequestWithJwt, res) => {
})
})
// @ts-expect-error Jwt props.
menuDataRouter.get("/SafehouseCategory", (req: RequestWithJwt, res) => {
res.json({
template:
@ -165,6 +165,7 @@ menuDataRouter.get("/SafehouseCategory", (req: RequestWithJwt, res) => {
})
})
// @ts-expect-error Jwt props.
menuDataRouter.get("/Safehouse", (req: RequestWithJwt<SafehouseQuery>, res) => {
const template = getConfig("LegacySafehouseTemplate", false)
@ -184,6 +185,7 @@ menuDataRouter.get("/Safehouse", (req: RequestWithJwt<SafehouseQuery>, res) => {
})
})
// @ts-expect-error Jwt props.
menuDataRouter.get("/report", (req: RequestWithJwt, res) => {
res.json({
template: getVersionedConfig("ReportTemplate", req.gameVersion, false),
@ -202,6 +204,7 @@ menuDataRouter.get("/report", (req: RequestWithJwt, res) => {
// /stashpoint?contractid=5b5f8aa4-ecb4-4a0a-9aff-98aa1de43dcc&slotid=6&slotname=stashpoint6&stashpoint=28b03709-d1f0-4388-b207-f03611eafb64&allowlargeitems=true&allowcontainers=false
menuDataRouter.get(
"/stashpoint",
// @ts-expect-error Jwt props.
(req: RequestWithJwt<StashpointQuery | StashpointQueryH2016>, res) => {
function isValidModernQuery(
query: StashpointQuery | StashpointQueryH2016,
@ -214,7 +217,7 @@ menuDataRouter.get(
if (["h1", "scpc"].includes(req.gameVersion)) {
// H1 or SCPC
if (!uuidRegex.test(req.query.contractid)) {
if (!uuidRegex.test(req.query.contractid!)) {
res.status(400).send("contract id was not a uuid")
return
}
@ -264,15 +267,22 @@ menuDataRouter.get(
menuDataRouter.get(
"/missionrewards",
// @ts-expect-error Jwt props.
(
req: RequestWithJwt<{
contractSessionId: string
}>,
res,
) => {
const { contractId } = getSession(req.jwt.unique_name)
const contractData = controller.resolveContract(contractId, true)
const s = getSession(req.jwt.unique_name)
if (!s) {
res.status(400).send("no session")
return
}
const { contractId } = s
const contractData = controller.resolveContract(contractId, true)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
res.json({
@ -321,7 +331,7 @@ menuDataRouter.get(
LocationHideProgression: true,
Difficulty: "normal", // FIXME: is this right?
CompletionData: generateCompletionData(
contractData.Metadata.Location,
contractData?.Metadata.Location || "",
req.jwt.unique_name,
req.gameVersion,
),
@ -332,6 +342,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/Planning",
// @ts-expect-error Jwt props.
async (req: RequestWithJwt<PlanningQuery>, res) => {
if (!req.query.contractid || !req.query.resetescalation) {
res.status(400).send("invalid query")
@ -341,7 +352,7 @@ menuDataRouter.get(
const planningData = await getPlanningData(
req.query.contractid,
req.query.resetescalation === "true",
req.jwt,
req.jwt.unique_name,
req.gameVersion,
)
@ -350,13 +361,16 @@ menuDataRouter.get(
return
}
let template: unknown | null = null
if (req.gameVersion === "h1") {
template = getConfig("LegacyPlanningTemplate", false)
} else if (req.gameVersion === "scpc") {
template = getConfig("FrankensteinPlanningTemplate", false)
}
res.json({
template:
req.gameVersion === "h1"
? getConfig("LegacyPlanningTemplate", false)
: req.gameVersion === "scpc"
? getConfig("FrankensteinPlanningTemplate", false)
: null,
template,
data: planningData,
})
},
@ -364,6 +378,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/selectagencypickup",
// @ts-expect-error Jwt props.
(
req: RequestWithJwt<{
contractId: string
@ -407,7 +422,7 @@ menuDataRouter.get(
contractData,
req.jwt.unique_name,
req.gameVersion,
),
)!,
}
res.json({
@ -440,14 +455,16 @@ menuDataRouter.get(
Contract: contractData,
OrderedUnlocks: unlockedAgencyPickups
.filter((unlockable) =>
pickupsInScene.includes(unlockable.Properties.RepositoryId),
pickupsInScene.includes(
unlockable.Properties.RepositoryId || "",
),
)
.sort(unlockOrderComparer),
UserCentric: generateUserCentric(
contractData,
req.jwt.unique_name,
req.gameVersion,
),
)!,
}
res.json({
@ -463,6 +480,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/selectentrance",
// @ts-expect-error Jwt props.
(
req: RequestWithJwt<{
contractId: string
@ -522,7 +540,7 @@ menuDataRouter.get(
contractData,
req.jwt.unique_name,
req.gameVersion,
),
)!,
}
res.json({
@ -580,6 +598,12 @@ const missionEndRequest = async (
return
}
// prototype pollution prevention
if (/(__proto__|prototype|constructor)/.test(req.query.contractSessionId)) {
res.status(400).send("invalid session id")
return
}
const missionEndOutput = await getMissionEndData(
req.query,
req.jwt,
@ -613,21 +637,29 @@ const missionEndRequest = async (
})
}
// @ts-expect-error Has jwt props.
menuDataRouter.get("/missionend", missionEndRequest)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/scoreoverviewandunlocks", missionEndRequest)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/scoreoverview", missionEndRequest)
menuDataRouter.get(
"/Destination",
// @ts-expect-error Jwt props.
(req: RequestWithJwt<GetDestinationQuery>, res) => {
if (!req.query.locationId) {
res.status(400).send("Invalid locationId")
return
}
const destination = getDestination(req.query, req.gameVersion, req.jwt)
const destination = getDestination(
req.query,
req.gameVersion,
req.jwt.unique_name,
)
res.json({
template:
@ -681,13 +713,9 @@ async function lookupContractPublicId(
menuDataRouter.get(
"/LookupContractPublicId",
async (
req: RequestWithJwt<{
publicid: string
}>,
res,
) => {
if (!req.query.publicid || typeof req.query.publicid !== "string") {
// @ts-expect-error Has jwt props.
async (req: RequestWithJwt<LookupContractPublicIdQuery>, res) => {
if (typeof req.query.publicid !== "string") {
return res.status(400).send("no/invalid public id specified!")
}
@ -708,6 +736,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/HitsCategory",
// @ts-expect-error Has jwt props.
async (
req: RequestWithJwt<{
type: string
@ -749,6 +778,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/PlayNext",
// @ts-expect-error Has jwt props.
(
req: RequestWithJwt<{
contractId: string
@ -764,14 +794,14 @@ menuDataRouter.get(
template: getConfig("PlayNextTemplate", false),
data: getGamePlayNextData(
req.query.contractId,
req.jwt,
req.jwt.unique_name,
req.gameVersion,
),
})
},
)
menuDataRouter.get("/LeaderboardsView", (req, res) => {
menuDataRouter.get("/LeaderboardsView", (_, res) => {
res.json({
template: getConfig("LeaderboardsViewTemplate", false),
data: {
@ -783,6 +813,7 @@ menuDataRouter.get("/LeaderboardsView", (req, res) => {
menuDataRouter.get(
"/LeaderboardEntries",
// @ts-expect-error Has jwt props.
async (req: RequestWithJwt<LeaderboardEntriesCommonQuery>, res) => {
if (!req.query.contractid) {
res.status(400).send("no contract id!")
@ -810,6 +841,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/DebriefingLeaderboards",
// @ts-expect-error Has jwt props.
async (req: RequestWithJwt<DebriefingLeaderboardsQuery>, res) => {
if (!req.query.contractid) {
res.status(400).send("no contract id!")
@ -835,10 +867,12 @@ menuDataRouter.get(
},
)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/Contracts", contractsModeHome)
menuDataRouter.get(
"/contractcreation/planning",
// @ts-expect-error Has jwt props.
async (
req: RequestWithJwt<{
contractCreationIdOverwrite: string
@ -858,7 +892,7 @@ menuDataRouter.get(
const planningData = await getPlanningData(
req.query.contractCreationIdOverwrite,
false,
req.jwt,
req.jwt.unique_name,
req.gameVersion,
)
@ -890,6 +924,7 @@ menuDataRouter.get(
},
)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/contractsearchpage", (req: RequestWithJwt, res) => {
const createContractTutorial = controller.resolveContract(
contractCreationTutorialId,
@ -920,6 +955,7 @@ menuDataRouter.get("/contractsearchpage", (req: RequestWithJwt, res) => {
menuDataRouter.post(
"/ContractSearch",
jsonMiddleware(),
// @ts-expect-error Jwt props.
async (
req: RequestWithJwt<
{
@ -977,12 +1013,21 @@ menuDataRouter.post(
}
} else {
// No plugins handle this. Getting search results from official
searchResult = await officialSearchContract(
searchResult = (await officialSearchContract(
req.jwt.unique_name,
req.gameVersion,
req.body,
0,
)
)) || {
Data: {
Contracts: [],
TotalCount: 0,
Page: 0,
ErrorReason: "",
HasPrevious: false,
HasMore: false,
},
}
}
res.json({
@ -999,6 +1044,7 @@ menuDataRouter.post(
menuDataRouter.post(
"/ContractSearchPaginate",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
async (
req: RequestWithJwt<
{
@ -1022,14 +1068,26 @@ menuDataRouter.post(
menuDataRouter.get(
"/DebriefingChallenges",
// @ts-expect-error Has jwt props.
(
req: RequestWithJwt<{
contractId: string
}>,
req: RequestWithJwt<
Partial<{
contractId: string
}>
>,
res,
) => {
if (typeof req.query.contractId !== "string") {
res.status(400).send("invalid contractId")
return
}
res.json({
template: getConfig("DebriefingChallengesTemplate", false),
template: getVersionedConfig(
"DebriefingChallengesTemplate",
req.gameVersion,
false,
),
data: {
ChallengeData: {
Children:
@ -1044,6 +1102,7 @@ menuDataRouter.get(
},
)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/contractcreation/create", (req: RequestWithJwt, res) => {
let cUuid = randomUUID()
const createContractReturnTemplate = getConfig(
@ -1059,6 +1118,11 @@ menuDataRouter.get("/contractcreation/create", (req: RequestWithJwt, res) => {
const sesh = getSession(req.jwt.unique_name)
if (!sesh) {
res.status(400).send("no session")
return
}
const one = "1"
const two = `${random.int(10, 99)}`
const three = `${random.int(1_000_000, 9_999_999)}`
@ -1091,25 +1155,23 @@ menuDataRouter.get("/contractcreation/create", (req: RequestWithJwt, res) => {
Description: "UI_CONTRACTS_UGC_DESCRIPTION",
Targets: Array.from(sesh.kills)
.filter((kill) =>
sesh.markedTargets.has(kill._RepositoryId),
sesh.markedTargets.has(kill._RepositoryId || ""),
)
.map((km) => {
return {
RepositoryId: km._RepositoryId,
Selected: true,
Weapon: {
RepositoryId: km.KillItemRepositoryId,
KillMethodBroad: km.KillMethodBroad,
KillMethodStrict: km.KillMethodStrict,
RequiredKillMethodType: 3,
},
Outfit: {
RepositoryId: km.OutfitRepoId,
Required: true,
IsHitmanSuit: isSuit(km.OutfitRepoId),
},
}
}),
.map((km) => ({
RepositoryId: km._RepositoryId,
Selected: true,
Weapon: {
RepositoryId: km.KillItemRepositoryId,
KillMethodBroad: km.KillMethodBroad,
KillMethodStrict: km.KillMethodStrict,
RequiredKillMethodType: 3,
},
Outfit: {
RepositoryId: km.OutfitRepoId,
Required: true,
IsHitmanSuit: isSuit(km.OutfitRepoId),
},
})),
ContractConditions: complications(timeLimitStr),
PublishingDisabled:
sesh.contractId === contractCreationTutorialId,
@ -1143,7 +1205,7 @@ const createLoadSaveMiddleware =
template,
data: {
Contracts: [] as UserCentricContract[],
PaymentEligiblity: {},
PaymentEligiblity: {} as Record<string, boolean>,
},
}
@ -1186,144 +1248,46 @@ const createLoadSaveMiddleware =
menuDataRouter.post(
"/Load",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
createLoadSaveMiddleware("LoadMenuTemplate"),
)
menuDataRouter.post(
"/Save",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
createLoadSaveMiddleware("SaveMenuTemplate"),
)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/PlayerProfile", (req: RequestWithJwt, res) => {
const playerProfilePage = getConfig<PlayerProfileView>(
"PlayerProfilePage",
true,
)
const locationData = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
req.gameVersion,
false,
)
playerProfilePage.data.SubLocationData = []
for (const subLocationKey in locationData.children) {
// Ewww...
if (
subLocationKey === "LOCATION_ICA_FACILITY_ARRIVAL" ||
subLocationKey.includes("SNUG_")
) {
continue
}
const subLocation = locationData.children[subLocationKey]
const parentLocation =
locationData.parents[subLocation.Properties.ParentLocation]
const completionData = generateCompletionData(
subLocation.Id,
req.jwt.unique_name,
req.gameVersion,
)
// TODO: Make getDestinationCompletion do something like this.
const challenges = controller.challengeService.getChallengesForLocation(
subLocation.Id,
req.gameVersion,
)
const challengeCategoryCompletion: ChallengeCategoryCompletion[] = []
for (const challengeGroup in challenges) {
const challengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
{
challengeGroup: challenges[challengeGroup],
},
req.jwt.unique_name,
req.gameVersion,
)
challengeCategoryCompletion.push({
Name: challenges[challengeGroup][0].CategoryName,
...challengeCompletion,
})
}
const destinationCompletion = getDestinationCompletion(
parentLocation,
subLocation,
req.gameVersion,
req.jwt,
)
playerProfilePage.data.SubLocationData.push({
ParentLocation: parentLocation,
Location: subLocation,
CompletionData: completionData,
ChallengeCategoryCompletion: challengeCategoryCompletion,
ChallengeCompletion: destinationCompletion.ChallengeCompletion,
OpportunityStatistics: destinationCompletion.OpportunityStatistics,
LocationCompletionPercent:
destinationCompletion.LocationCompletionPercent,
})
}
const userProfile = getUserData(req.jwt.unique_name, req.gameVersion)
playerProfilePage.data.PlayerProfileXp.Total =
userProfile.Extensions.progression.PlayerProfileXP.Total
playerProfilePage.data.PlayerProfileXp.Level =
userProfile.Extensions.progression.PlayerProfileXP.ProfileLevel
const subLocationMap = new Map(
userProfile.Extensions.progression.PlayerProfileXP.Sublocations.map(
(obj) => [obj.Location, obj],
),
)
for (const e of playerProfilePage.data.PlayerProfileXp.Seasons) {
for (const f of e.Locations) {
const subLocationData = subLocationMap.get(f.LocationId)
f.Xp = subLocationData?.Xp || 0
f.ActionXp = subLocationData?.ActionXp || 0
if (f.LocationProgression && !isSniperLocation(f.LocationId)) {
// We typecast below as it could be an object for subpackages.
// Checks before this ensure it isn't, but TS doesn't realise this.
f.LocationProgression.Level =
(
userProfile.Extensions.progression.Locations[
f.LocationId
] as ProgressionData
).Level || 1
}
}
}
res.json(playerProfilePage)
res.json({
template: null,
data: getPlayerProfileData(req.gameVersion, req.jwt.unique_name),
})
})
menuDataRouter.get(
// who at IOI decided this was a good route name???!
"/LookupContractDialogAddOrDeleteFromPlaylist",
// @ts-expect-error Has jwt props.
withLookupDialog,
)
menuDataRouter.get(
// this one is sane Kappa
"/contractplaylist/addordelete/:contractId",
// @ts-expect-error Has jwt props.
directRoute,
)
menuDataRouter.post(
"/contractplaylist/deletemultiple",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
deleteMultiple,
)
// @ts-expect-error Has jwt props.
menuDataRouter.get("/GetPlayerProfileXpData", (req: RequestWithJwt, res) => {
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
@ -1342,7 +1306,13 @@ menuDataRouter.get("/GetPlayerProfileXpData", (req: RequestWithJwt, res) => {
menuDataRouter.get(
"/GetMasteryCompletionDataForLocation",
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<GetCompletionDataForLocationQuery>, res) => {
if (!req.query.locationId) {
res.status(400).send("no location id")
return
}
res.json(
generateCompletionData(
req.query.locationId,
@ -1355,6 +1325,7 @@ menuDataRouter.get(
menuDataRouter.get(
"/MasteryUnlockable",
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<MasteryUnlockableQuery>, res) => {
let masteryUnlockTemplate = getConfig(
"MasteryUnlockablesTemplate",
@ -1402,32 +1373,30 @@ menuDataRouter.get(
menuDataRouter.get(
"/MasteryDataForLocation",
// @ts-expect-error Has jwt props.
(
req: RequestWithJwt<{
locationId: string
}>,
res,
) => {
res.json(
controller.masteryService.getMasteryDataForLocation(
res.json({
template: getConfig("MasteryDataForLocationTemplate", false),
data: controller.masteryService.getMasteryDataForLocation(
req.query.locationId,
req.gameVersion,
req.jwt.unique_name,
),
)
})
},
)
menuDataRouter.get(
"/GetMasteryCompletionDataForUnlockable",
(
req: RequestWithJwt<{
unlockableId: string
}>,
res,
) => {
// @ts-expect-error Has jwt props.
(req: RequestWithJwt<GetMasteryCompletionDataForUnlockableQuery>, res) => {
// We make this lookup table to quickly get it, there's no other quick way for it.
const unlockToLoc = {
const unlockToLoc: Record<string, string> = {
FIREARMS_SC_HERO_SNIPER_HM: "LOCATION_PARENT_AUSTRIA",
FIREARMS_SC_HERO_SNIPER_KNIGHT: "LOCATION_PARENT_AUSTRIA",
FIREARMS_SC_HERO_SNIPER_STONE: "LOCATION_PARENT_AUSTRIA",

View File

@ -21,8 +21,8 @@ import type {
Campaign,
GameVersion,
GenSingleMissionFunc,
ICampaignMission,
ICampaignVideo,
CampaignMission,
CampaignVideo,
IVideo,
StoryData,
} from "../types/types"
@ -37,7 +37,7 @@ const genSingleMissionFactory = (userId: string): GenSingleMissionFunc => {
return function genSingleMission(
contractId: string,
gameVersion: GameVersion,
): ICampaignMission {
): CampaignMission {
assert.ok(
contractId,
"Plugin tried to generate mission with no contract ID",
@ -51,11 +51,12 @@ const genSingleMissionFactory = (userId: string): GenSingleMissionFunc => {
if (!actualContractData) {
log(LogLevel.ERROR, `Failed to resolve contract ${contractId}!`)
assert.fail(`Failed to resolve contract ${contractId}! (campaign)`)
}
return {
Type: "Mission",
Data: contractIdToHitObject(contractId, gameVersion, userId),
Data: contractIdToHitObject(contractId, gameVersion, userId)!,
}
}
}
@ -63,7 +64,7 @@ const genSingleMissionFactory = (userId: string): GenSingleMissionFunc => {
function genSingleVideo(
videoId: string,
gameVersion: GameVersion,
): ICampaignVideo {
): CampaignVideo {
const videos = getConfig<Record<string, IVideo>>("Videos", true) // we modify videos so we need to clone this
const video = videos[videoId]

View File

@ -18,16 +18,18 @@
import { getConfig, getVersionedConfig } from "../configSwizzleManager"
import type {
ChallengeCompletion,
CompiledChallengeTreeCategory,
CompletionData,
GameLocationsData,
GameVersion,
IHit,
JwtData,
MissionStory,
OpportunityStatistics,
PeacockLocationsData,
Unlockable,
} from "../types/types"
import type { MasteryData } from "../types/mastery"
import { contractIdToHitObject, controller } from "../controller"
import { generateCompletionData } from "../contracts/dataGen"
import { getUserData } from "../databaseHandler"
@ -37,6 +39,17 @@ import { createInventory } from "../inventory"
import { log, LogLevel } from "../loggingInterop"
import { no2016 } from "../contracts/escalations/escalationService"
import { missionsInLocations } from "../contracts/missionsInLocation"
import assert from "assert"
type LegacyData = {
[difficulty: string]: {
ChallengeCompletion: {
ChallengesCount: number
CompletedChallengesCount: number
}
CompletionData: CompletionData
}
}
type GameFacingDestination = {
ChallengeCompletion: {
@ -48,14 +61,45 @@ type GameFacingDestination = {
LocationCompletionPercent: number
Location: Unlockable
// H2016 only
Data?: {
[difficulty: string]: {
ChallengeCompletion: {
ChallengesCount: number
CompletedChallengesCount: number
}
CompletionData: CompletionData
Data?: LegacyData
}
type LocationMissionData = {
Location: Unlockable
SubLocation: Unlockable
Missions: IHit[]
SarajevoSixMissions: IHit[]
ElusiveMissions: IHit[]
EscalationMissions: IHit[]
SniperMissions: IHit[]
PlaceholderMissions: IHit[]
CampaignMissions: IHit[]
CompletionData: CompletionData
}
type GameDestination = {
ChallengeData: {
Children: CompiledChallengeTreeCategory[]
}
DifficultyData: {
AvailableDifficultyModes: {
Name: string
Available: boolean
}[]
Difficulty: string | undefined
LocationId: string
}
Location: Unlockable
MasteryData: MasteryData | MasteryData[] | Record<string, never>
MissionData: {
ChallengeCompletion: ChallengeCompletion
Location: Unlockable
LocationCompletionPercent: number
OpportunityStatistics: {
Completed: number
Count: number
}
SubLocationMissionsData: LocationMissionData[]
}
}
@ -63,14 +107,14 @@ export function getDestinationCompletion(
parent: Unlockable,
child: Unlockable | undefined,
gameVersion: GameVersion,
jwt: JwtData,
userId: string,
) {
const missionStories = getConfig<Record<string, MissionStory>>(
"MissionStories",
false,
)
const userData = getUserData(jwt.unique_name, gameVersion)
const userData = getUserData(userId, gameVersion)
const challenges = controller.challengeService.getGroupedChallengeLists(
{
type: ChallengeFilterType.ParentLocation,
@ -123,21 +167,10 @@ export function getCompletionPercent(
opportunityDone: number,
opportunityTotal: number,
): number {
if (challengeDone === undefined) {
challengeDone = 0
}
if (challengeTotal === undefined) {
challengeTotal = 0
}
if (opportunityDone === undefined) {
opportunityDone = 0
}
if (opportunityTotal === undefined) {
opportunityTotal = 0
}
challengeDone ??= 0
challengeTotal ??= 0
opportunityDone ??= 0
opportunityTotal ??= 0
const totalCompletables = challengeTotal + opportunityTotal
const totalCompleted = challengeDone + opportunityDone
@ -150,11 +183,11 @@ export function getCompletionPercent(
* Get the list of destinations used by the `/profiles/page/Destinations` endpoint.
*
* @param gameVersion
* @param jwt
* @param userId The user ID.
*/
export function getAllGameDestinations(
gameVersion: GameVersion,
jwt: JwtData,
userId: string,
): GameFacingDestination[] {
const result: GameFacingDestination[] = []
const locations = getVersionedConfig<PeacockLocationsData>(
@ -169,38 +202,13 @@ export function getAllGameDestinations(
"UI_LOCATION_PARENT_" + destination.substring(16) + "_NAME"
const template: GameFacingDestination = {
...getDestinationCompletion(parent, undefined, gameVersion, jwt),
...getDestinationCompletion(parent, undefined, gameVersion, userId),
...{
CompletionData: generateCompletionData(
destination,
jwt.unique_name,
userId,
gameVersion,
),
Data:
gameVersion === "h1"
? {
normal: {
ChallengeCompletion: undefined,
CompletionData: generateCompletionData(
destination,
jwt.unique_name,
gameVersion,
"mission",
"normal",
),
},
pro1: {
ChallengeCompletion: undefined,
CompletionData: generateCompletionData(
destination,
jwt.unique_name,
gameVersion,
"mission",
"pro1",
),
},
}
: undefined,
},
}
@ -208,10 +216,28 @@ export function getAllGameDestinations(
// 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 (gameVersion === "h1") {
template.Data.normal.ChallengeCompletion =
template.ChallengeCompletion
template.Data.pro1.ChallengeCompletion =
template.ChallengeCompletion
template.Data = {
normal: {
ChallengeCompletion: template.ChallengeCompletion,
CompletionData: generateCompletionData(
destination,
userId,
gameVersion,
"mission",
"normal",
),
},
pro1: {
ChallengeCompletion: template.ChallengeCompletion,
CompletionData: generateCompletionData(
destination,
userId,
gameVersion,
"mission",
"pro1",
),
},
} satisfies LegacyData
}
result.push(template)
@ -258,10 +284,15 @@ export function createLocationsData(
}
const sublocation = locData.children[sublocationId]
if (!sublocation.Properties.ParentLocation) {
assert.fail("sublocation has no parent, that's illegal")
}
const parentLocation =
locData.parents[sublocation.Properties.ParentLocation]
const creationContract = controller.resolveContract(
sublocation.Properties.CreateContractId,
sublocation.Properties.CreateContractId!,
)
if (!creationContract && excludeIfNoContracts) {
@ -288,12 +319,18 @@ export function createLocationsData(
return finalData
}
// TODO: this is a mess, write docs and type explicitly
/**
* This gets the game-facing data for a destination.
*
* @param query
* @param gameVersion
* @param userId
*/
export function getDestination(
query: GetDestinationQuery,
gameVersion: GameVersion,
jwt: JwtData,
) {
userId: string,
): GameDestination {
const LOCATION = query.locationId
const locData = getVersionedConfig<PeacockLocationsData>(
@ -306,18 +343,30 @@ export function getDestination(
const masteryData = controller.masteryService.getMasteryDataForDestination(
query.locationId,
gameVersion,
jwt.unique_name,
userId,
query.difficulty,
)
const response = {
Location: {},
let resMasteryData: GameDestination["MasteryData"]
if (LOCATION !== "LOCATION_PARENT_ICA_FACILITY") {
if (gameVersion === "h1") {
resMasteryData = masteryData[0]
} else {
resMasteryData = masteryData
}
} else {
resMasteryData = {}
}
const response: Partial<GameDestination> = {
Location: locationData,
MissionData: {
...getDestinationCompletion(
locationData,
undefined,
gameVersion,
jwt,
userId,
),
...{ SubLocationMissionsData: [] },
},
@ -326,20 +375,14 @@ export function getDestination(
controller.challengeService.getChallengeDataForDestination(
query.locationId,
gameVersion,
jwt.unique_name,
userId,
),
},
MasteryData:
LOCATION !== "LOCATION_PARENT_ICA_FACILITY"
? gameVersion === "h1"
? masteryData[0]
: masteryData
: {},
DifficultyData: undefined,
MasteryData: resMasteryData,
}
if (gameVersion === "h1" && LOCATION !== "LOCATION_PARENT_ICA_FACILITY") {
const inventory = createInventory(jwt.unique_name, gameVersion)
const inventory = createInventory(userId, gameVersion)
response.DifficultyData = {
AvailableDifficultyModes: [
@ -352,7 +395,7 @@ export function getDestination(
Available: inventory.some(
(e) =>
e.Unlockable.Id ===
locationData.Properties.DifficultyUnlock.pro1,
locationData.Properties.DifficultyUnlock?.pro1,
),
},
],
@ -369,15 +412,15 @@ export function getDestination(
(subLocation) => subLocation.Properties.ParentLocation === LOCATION,
)
response.Location = locationData
if (query.difficulty === "pro1") {
const obj = {
type Cast = keyof typeof controller.missionsInLocations.pro1
const obj: LocationMissionData = {
Location: locationData,
SubLocation: locationData,
Missions: [controller.missionsInLocations.pro1[LOCATION]].map(
(id) => contractIdToHitObject(id, gameVersion, jwt.unique_name),
),
Missions: [controller.missionsInLocations.pro1[LOCATION as Cast]]
.map((id) => contractIdToHitObject(id, gameVersion, userId))
.filter(Boolean) as IHit[],
SarajevoSixMissions: [],
ElusiveMissions: [],
EscalationMissions: [],
@ -386,14 +429,14 @@ export function getDestination(
CampaignMissions: [],
CompletionData: generateCompletionData(
sublocationsData[0].Id,
jwt.unique_name,
userId,
gameVersion,
),
}
response.MissionData.SubLocationMissionsData.push(obj)
response.MissionData?.SubLocationMissionsData.push(obj)
return response
return response as GameDestination
}
for (const e of sublocationsData) {
@ -401,6 +444,7 @@ export function getDestination(
const escalations: IHit[] = []
type ECast = keyof typeof controller.missionsInLocations.escalations
// every unique escalation from the sublocation
const allUniqueEscalations: string[] = [
...(gameVersion === "h1" && e.Id === "LOCATION_ICA_FACILITY"
@ -409,7 +453,7 @@ export function getDestination(
]
: []),
...new Set<string>(
controller.missionsInLocations.escalations[e.Id] || [],
controller.missionsInLocations.escalations[e.Id as ECast] || [],
),
]
@ -419,7 +463,7 @@ export function getDestination(
const details = contractIdToHitObject(
escalation,
gameVersion,
jwt.unique_name,
userId,
)
if (details) {
@ -428,17 +472,18 @@ export function getDestination(
}
const sniperMissions: IHit[] = []
type SCast = keyof typeof controller.missionsInLocations.sniper
for (const sniperMission of controller.missionsInLocations.sniper[
e.Id
e.Id as SCast
] ?? []) {
sniperMissions.push(
contractIdToHitObject(
sniperMission,
gameVersion,
jwt.unique_name,
),
const hit = contractIdToHitObject(
sniperMission,
gameVersion,
userId,
)
if (hit) sniperMissions.push(hit)
}
const obj = {
@ -451,11 +496,7 @@ export function getDestination(
SniperMissions: sniperMissions,
PlaceholderMissions: [],
CampaignMissions: [],
CompletionData: generateCompletionData(
e.Id,
jwt.unique_name,
gameVersion,
),
CompletionData: generateCompletionData(e.Id, userId, gameVersion),
}
const types = [
@ -464,6 +505,7 @@ export function getDestination(
["elusive", "ElusiveMissions"],
],
...((gameVersion === "h1" &&
// @ts-expect-error Hack.
missionsInLocations.sarajevo["h2016enabled"]) ||
gameVersion === "h3"
? [["sarajevo", "SarajevoSixMissions"]]
@ -472,8 +514,10 @@ export function getDestination(
for (const t of types) {
let theMissions: string[] | undefined = !t[0] // no specific type
? controller.missionsInLocations[e.Id]
: controller.missionsInLocations[t[0]][e.Id]
? // @ts-expect-error Yup.
controller.missionsInLocations[e.Id]
: // @ts-expect-error Yup.
controller.missionsInLocations[t[0]][e.Id]
// edge case: ica facility in h1 was only 1 sublocation, so we merge
// these into a single array
@ -504,16 +548,17 @@ export function getDestination(
const mission = contractIdToHitObject(
c,
gameVersion,
jwt.unique_name,
userId,
)
// @ts-expect-error Yup.
obj[t[1]].push(mission)
}
}
}
response.MissionData.SubLocationMissionsData.push(obj)
response.MissionData?.SubLocationMissionsData.push(obj)
}
return response
return response as GameDestination
}

View File

@ -82,7 +82,7 @@ export function withLookupDialog(
contract,
req.jwt.unique_name,
req.gameVersion,
),
)!,
},
...(flag && { AddedSuccessfully: true }),
}

View File

@ -16,7 +16,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { GameVersion, JwtData, PeacockLocationsData } from "../types/types"
import type {
CompletionData,
GameVersion,
PeacockLocationsData,
Unlockable,
} from "../types/types"
import { swapToBrowsingMenusStatus } from "../discordRp"
import { getUserData } from "../databaseHandler"
import { controller } from "../controller"
@ -29,10 +34,32 @@ import {
import { createLocationsData, getAllGameDestinations } from "./destinations"
import { makeCampaigns } from "./campaigns"
export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
type CareerEntry = {
Children: CareerEntryChild[]
Name: string
Location: Unlockable
}
type CareerEntryChild = {
IsLocked: boolean
Name: string
Image: string
Icon: string
CompletedChallengesCount: number
ChallengesCount: number
CategoryId: string
Description: string
Location: Unlockable
ImageLocked: string
RequiredResources: string[]
IsPack?: boolean
CompletionData: CompletionData
}
export function getHubData(gameVersion: GameVersion, userId: string) {
swapToBrowsingMenusStatus(gameVersion)
const userdata = getUserData(jwt.unique_name, gameVersion)
const userdata = getUserData(userId, gameVersion)
const contractCreationTutorial =
gameVersion !== "scpc"
@ -44,7 +71,7 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
gameVersion,
true,
)
const career =
const career: Record<string, CareerEntry> =
gameVersion === "h3"
? {}
: {
@ -73,7 +100,7 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
controller.masteryService.getMasteryDataForDestination(
parent,
gameVersion,
jwt.unique_name,
userId,
).length
) {
const completionData =
@ -81,7 +108,7 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
parent,
parent,
gameVersion,
jwt.unique_name,
userId,
parent.includes("SNUG") ? "evergreen" : "mission",
gameVersion === "h1" ? "normal" : undefined,
)
@ -100,7 +127,7 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
parent,
parent,
gameVersion,
jwt.unique_name,
userId,
parent.includes("SNUG")
? "evergreen"
: "mission",
@ -137,14 +164,14 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
const challengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
challenges,
jwt.unique_name,
userId,
gameVersion,
)
career[parent]?.Children.push({
IsLocked: location.Properties.IsLocked,
career[parent!]?.Children.push({
IsLocked: Boolean(location.Properties.IsLocked),
Name: location.DisplayNameLocKey,
Image: location.Properties.Icon,
Image: location.Properties.Icon || "",
Icon: location.Type, // should be "location" for all locations
CompletedChallengesCount:
challengeCompletion.CompletedChallengesCount,
@ -152,14 +179,10 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
CategoryId: child,
Description: `UI_${child}_PRIMARY_DESC`,
Location: location,
ImageLocked: location.Properties.LockedIcon,
RequiredResources: location.Properties.RequiredResources,
ImageLocked: location.Properties.LockedIcon || "",
RequiredResources: location.Properties.RequiredResources || [],
IsPack: false, // should be false for all locations
CompletionData: generateCompletionData(
child,
jwt.unique_name,
gameVersion,
),
CompletionData: generateCompletionData(child, userId, gameVersion),
})
}
@ -176,10 +199,10 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
},
},
DashboardData: [],
DestinationsData: getAllGameDestinations(gameVersion, jwt),
DestinationsData: getAllGameDestinations(gameVersion, userId),
CreateContractTutorial: generateUserCentric(
contractCreationTutorial,
jwt.unique_name,
userId,
gameVersion,
),
LocationsData: createLocationsData(gameVersion, true),
@ -189,7 +212,7 @@ export function getHubData(gameVersion: GameVersion, jwt: JwtData) {
},
MasteryData: masteryData,
},
StoryData: makeCampaigns(gameVersion, jwt.unique_name),
StoryData: makeCampaigns(gameVersion, userId),
FilterData: getVersionedConfig("FilterData", gameVersion, false),
StoreData: getVersionedConfig("StoreData", gameVersion, false),
IOIAccountStatus: {

View File

@ -544,8 +544,11 @@ export const menuSystemDatabase = new MenuSystemDatabase()
menuSystemRouter.get(
"/dynamic_resources_pc_release_rpkg",
// @ts-expect-error No type issue is actually here.
async (req: RequestWithJwt, res) => {
const dynamicResourceName = `dynamic_resources_${req.gameVersion}.rpkg`
const dynamicResourceName = `dynamic_resources_${
req.gameVersion === "scpc" ? "h1" : req.gameVersion
}.rpkg`
const dynamicResourcePath = join(
PEACOCK_DEV ? process.cwd() : __dirname,
"resources",
@ -565,6 +568,7 @@ menuSystemRouter.get(
},
)
// @ts-expect-error No type issue is actually here.
menuSystemRouter.use("/menusystem/", MenuSystemDatabase.configMiddleware)
// Miranda Jamison's image path in the repository is escaped for some reason
@ -587,6 +591,7 @@ menuSystemPreRouter.get(
menuSystemRouter.use(
"/images/",
// @ts-expect-error No type issue is actually here.
serveStatic("images", { fallthrough: true }),
imageFetchingMiddleware,
)

View File

@ -19,9 +19,9 @@
import type {
CompiledChallengeTreeCategory,
GameVersion,
JwtData,
MissionManifest,
MissionStory,
ProgressionData,
SceneConfig,
Unlockable,
UserCentricContract,
@ -51,11 +51,12 @@ import {
} from "../utils"
import { createInventory, getUnlockableById } from "../inventory"
import { createSniperLoadouts } from "./sniper"
import { createSniperLoadouts, SniperCharacter, SniperLoadout } from "./sniper"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
import { resolveProfiles } from "../profileHandler"
import { userAuths } from "../officialServerAuth"
import assert from "assert"
export type PlanningError = { error: boolean }
@ -77,11 +78,11 @@ export type GamePlanningData = {
IsFirstInGroup: boolean
Creator: UserProfile
UserContract?: boolean
UnlockedEntrances?: string[]
UnlockedAgencyPickups?: string[]
UnlockedEntrances?: string[] | null
UnlockedAgencyPickups?: string[] | null
Objectives?: unknown
GroupData?: PlanningGroupData
Entrances: Unlockable[]
Entrances: Unlockable[] | null
Location: Unlockable
LoadoutData: unknown
LimitedLoadoutUnlockLevel: number
@ -113,7 +114,7 @@ export type GamePlanningData = {
export async function getPlanningData(
contractId: string,
resetEscalation: boolean,
jwt: JwtData,
userId: string,
gameVersion: GameVersion,
): Promise<PlanningError | GamePlanningData> {
const entranceData = getConfig<SceneConfig>("Entrances", false)
@ -122,7 +123,7 @@ export async function getPlanningData(
false,
)
const userData = getUserData(jwt.unique_name, gameVersion)
const userData = getUserData(userId, gameVersion)
for (const ms in userData.Extensions.opportunityprogression) {
if (Object.keys(missionStories).includes(ms)) {
@ -130,13 +131,24 @@ export async function getPlanningData(
}
}
let contractData =
let contractData: MissionManifest | undefined
if (
gameVersion === "h1" &&
contractId === "42bac555-bbb9-429d-a8ce-f1ffdf94211c"
? _legacyBull
: contractId === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(contractId)
) {
contractData = _legacyBull
} else if (contractId === "ff9f46cf-00bd-4c12-b887-eac491c3a96d") {
contractData = _theLastYardbirdScpc
} else {
contractData = controller.resolveContract(contractId)
}
if (!contractData) {
return {
error: true,
}
}
if (resetEscalation) {
const escalationGroupId =
@ -144,10 +156,20 @@ export async function getPlanningData(
resetUserEscalationProgress(userData, escalationGroupId)
writeUserData(jwt.unique_name, gameVersion)
writeUserData(userId, gameVersion)
const group = controller.escalationMappings.get(escalationGroupId)
if (!group) {
log(
LogLevel.ERROR,
`Unknown escalation group: ${escalationGroupId}`,
)
return { error: true }
}
// now reassign properties and continue
contractId = controller.escalationMappings.get(escalationGroupId)["1"]
contractId = group["1"]
contractData = controller.resolveContract(contractId)
}
@ -161,20 +183,29 @@ export async function getPlanningData(
LogLevel.WARN,
`Trying to download contract ${contractId} due to it not found locally.`,
)
const user = userAuths.get(jwt.unique_name)
const resp = await user._useService(
const user = userAuths.get(userId)
const resp = await user?._useService(
`https://${getRemoteService(
gameVersion,
)}.hitman.io/profiles/page/Planning?contractid=${contractId}&resetescalation=false&forcecurrentcontract=false&errorhandling=false`,
true,
)
contractData = resp.data.data.Contract
contractData = resp?.data.data.Contract
if (!contractData) {
log(
LogLevel.ERROR,
`Official planning lookup no result: ${contractId}`,
)
return { error: true }
}
controller.fetchedContracts.set(contractData.Metadata.Id, contractData)
}
if (!contractData) {
log(LogLevel.ERROR, `Not found: ${contractId}, .`)
log(LogLevel.ERROR, `Not found: ${contractId}, planning regular.`)
return { error: true }
}
@ -198,6 +229,11 @@ export async function getPlanningData(
if (escalation) {
const groupContractData = controller.resolveContract(escalationGroupId)
if (!groupContractData) {
log(LogLevel.ERROR, `Not found: ${contractId}, planning esc group`)
return { error: true }
}
const p = getUserEscalationProgress(userData, escalationGroupId)
const done =
@ -216,9 +252,12 @@ export async function getPlanningData(
// Fix contractData to the data of the level in the group.
if (!contractData.Metadata.InGroup) {
contractData = controller.resolveContract(
contractData.Metadata.GroupDefinition.Order[p - 1],
)
const newLevelId =
contractData.Metadata.GroupDefinition?.Order[p - 1]
assert(typeof newLevelId === "string", "newLevelId is not a string")
contractData = controller.resolveContract(newLevelId)
}
}
@ -246,16 +285,21 @@ export async function getPlanningData(
const sublocation = getSubLocationFromContract(contractData, gameVersion)
assert.ok(sublocation, "contract sublocation is null")
if (!entranceData[scenePath]) {
log(
LogLevel.ERROR,
`Could not find Entrance data for ${scenePath} (loc Planning)! This may cause an unhandled promise rejection.`,
`Could not find Entrance data for ${scenePath} in planning`,
)
return {
error: true,
}
}
const entrancesInScene = entranceData[scenePath]
const typedInv = createInventory(jwt.unique_name, gameVersion, sublocation)
const typedInv = createInventory(userId, gameVersion, sublocation)
const unlockedEntrances = typedInv
.filter((item) => item.Unlockable.Type === "access")
@ -267,6 +311,9 @@ export async function getPlanningData(
LogLevel.ERROR,
"No matching entrance data found in planning, this is a bug!",
)
return {
error: true,
}
}
sublocation.DisplayNameLocKey = `UI_${sublocation.Id}_NAME`
@ -283,7 +330,7 @@ export async function getPlanningData(
let suit = getDefaultSuitFor(sublocation)
let tool1 = "TOKEN_FIBERWIRE"
let tool2 = "PROP_TOOL_COIN"
let briefcaseProp: string | undefined = undefined
let briefcaseContainedItemId: string | undefined = undefined
let briefcaseId: string | undefined = undefined
const dlForLocation =
@ -293,16 +340,13 @@ export async function getPlanningData(
contractData.Metadata.Location
]
: // new loadout profiles system
Object.hasOwn(
currentLoadout.data,
contractData.Metadata.Location,
) && currentLoadout.data[contractData.Metadata.Location]
currentLoadout.data[contractData.Metadata.Location]
if (dlForLocation) {
pistol = dlForLocation["2"]
suit = dlForLocation["3"]
tool1 = dlForLocation["4"]
tool2 = dlForLocation["5"]
pistol = dlForLocation["2"]!
suit = dlForLocation["3"]!
tool1 = dlForLocation["4"]!
tool2 = dlForLocation["5"]!
for (const key of Object.keys(dlForLocation)) {
if (["2", "3", "4", "5"].includes(key)) {
@ -311,28 +355,30 @@ export async function getPlanningData(
}
briefcaseId = key
briefcaseProp = dlForLocation[key]
// @ts-expect-error This will work.
briefcaseContainedItemId = dlForLocation[key]
}
}
const i = typedInv.find((item) => item.Unlockable.Id === briefcaseProp)
const userCentric = generateUserCentric(
contractData,
jwt.unique_name,
gameVersion,
const briefcaseContainedItem = typedInv.find(
(item) => item.Unlockable.Id === briefcaseContainedItemId,
)
const userCentric = generateUserCentric(contractData, userId, gameVersion)
const sniperLoadouts = createSniperLoadouts(
jwt.unique_name,
userId,
gameVersion,
contractData,
)
if (gameVersion === "scpc") {
for (const loadout of sniperLoadouts) {
loadout["LoadoutData"] = loadout["Loadout"]["LoadoutData"]
delete loadout["Loadout"]
const l = loadout as SniperLoadout
l["LoadoutData"] = (loadout as SniperCharacter)["Loadout"][
"LoadoutData"
]
delete (loadout as Partial<SniperCharacter>)["Loadout"]
}
}
@ -395,19 +441,20 @@ export async function getPlanningData(
SlotId: "6",
Recommended: null,
},
briefcaseId && {
SlotName: briefcaseProp,
SlotId: briefcaseId,
Recommended: {
item: {
...i,
Properties: {},
briefcaseId &&
briefcaseContainedItem && {
SlotName: briefcaseContainedItemId,
SlotId: briefcaseId,
Recommended: {
item: {
...briefcaseContainedItem,
Properties: {},
},
type: briefcaseContainedItem.Unlockable.Id,
owned: true,
},
type: i.Unlockable.Id,
owned: true,
IsContainer: true,
},
IsContainer: true,
},
].filter(Boolean)
/**
@ -426,7 +473,8 @@ export async function getPlanningData(
) {
const loadoutUnlockable = getUnlockableById(
gameVersion === "h1"
? sublocation?.Properties?.NormalLoadoutUnlock[
? // @ts-expect-error This works.
sublocation?.Properties?.NormalLoadoutUnlock[
contractData.Metadata.Difficulty ?? "normal"
]
: sublocation?.Properties?.NormalLoadoutUnlock,
@ -440,23 +488,31 @@ export async function getPlanningData(
gameVersion,
)
const locationProgression =
loadoutMasteryData &&
(loadoutMasteryData.SubPackageId
? userData.Extensions.progression.Locations[
const locationProgression: ProgressionData =
loadoutMasteryData?.SubPackageId
? // @ts-expect-error This works
userData.Extensions.progression.Locations[
loadoutMasteryData.Location
][loadoutMasteryData.SubPackageId]
: userData.Extensions.progression.Locations[
loadoutMasteryData.Location
])
loadoutMasteryData?.Location as unknown as string
]
if (locationProgression.Level < loadoutMasteryData.Level)
if (locationProgression.Level < (loadoutMasteryData?.Level || 0)) {
type S = {
SlotId: string
}
loadoutSlots = loadoutSlots.filter(
(slot) => !["2", "4", "5"].includes(slot.SlotId),
(slot) => !["2", "4", "5"].includes((slot as S)?.SlotId),
)
}
}
}
assert.ok(contractData, "no contract data at final - planning")
type Cast = keyof typeof limitedLoadoutUnlockLevelMap
return {
Contract: contractData,
ElusiveContractState: "not_completed",
@ -467,7 +523,7 @@ export async function getPlanningData(
UnlockedEntrances:
contractData.Metadata.Type === "sniper"
? null
: typedInv
: (typedInv
.filter(
(item) =>
item.Unlockable.Subtype === "startinglocation",
@ -475,27 +531,28 @@ export async function getPlanningData(
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
contractData!.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
.filter(Boolean) as string[]),
UnlockedAgencyPickups:
contractData.Metadata.Type === "sniper"
? null
: typedInv
: (typedInv
.filter((item) => item.Unlockable.Type === "agencypickup")
.filter(
(item) =>
item.Unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
// we already know it's not undefined
contractData!.Metadata.Difficulty,
)
.map((i) => i.Unlockable.Properties.RepositoryId)
.filter((id) => id),
.filter(Boolean) as string[]),
Objectives: mapObjectives(
contractData.Data.Objectives,
contractData.Data.Objectives!,
contractData.Data.GameChangers || [],
contractData.Metadata.GroupObjectiveDisplayOrder || [],
contractData.Metadata.IsEvergreenSafehouse,
Boolean(contractData.Metadata.IsEvergreenSafehouse),
),
GroupData: groupData,
Entrances:
@ -504,27 +561,28 @@ export async function getPlanningData(
: unlockedEntrances
.filter((unlockable) =>
entrancesInScene.includes(
unlockable.Properties.RepositoryId,
unlockable.Properties.RepositoryId || "",
),
)
.filter(
(unlockable) =>
unlockable.Properties.Difficulty ===
contractData.Metadata.Difficulty,
// we already know it's not undefined
contractData!.Metadata.Difficulty,
)
.sort(unlockOrderComparer),
Location: sublocation,
LoadoutData:
contractData.Metadata.Type === "sniper" ? null : loadoutSlots,
LimitedLoadoutUnlockLevel:
limitedLoadoutUnlockLevelMap[sublocation.Id] ?? 0,
limitedLoadoutUnlockLevelMap[sublocation.Id as Cast] ?? 0,
CharacterLoadoutData:
sniperLoadouts.length !== 0 ? sniperLoadouts : null,
ChallengeData: {
Children: controller.challengeService.getChallengeTreeForContract(
contractId,
gameVersion,
jwt.unique_name,
userId,
),
},
Currency: {

View File

@ -0,0 +1,148 @@
/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2024 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 { getConfig, getVersionedConfig } from "../configSwizzleManager"
import type {
ChallengeCategoryCompletion,
GameVersion,
PeacockLocationsData,
PlayerProfileView,
ProgressionData,
} from "../types/types"
import { generateCompletionData } from "../contracts/dataGen"
import { controller } from "../controller"
import { getDestinationCompletion } from "./destinations"
import { getUserData } from "../databaseHandler"
import { isSniperLocation } from "../utils"
export function getPlayerProfileData(
gameVersion: GameVersion,
userId: string,
): PlayerProfileView {
const playerProfilePage = getConfig<PlayerProfileView>(
"PlayerProfilePage",
true,
)
const locationData = getVersionedConfig<PeacockLocationsData>(
"LocationsData",
gameVersion,
false,
)
playerProfilePage.SubLocationData = []
for (const subLocationKey in locationData.children) {
// Ewww...
if (
subLocationKey === "LOCATION_ICA_FACILITY_ARRIVAL" ||
subLocationKey.includes("SNUG_")
) {
continue
}
const subLocation = locationData.children[subLocationKey]
const parentLocation =
locationData.parents[subLocation.Properties.ParentLocation || ""]
const completionData = generateCompletionData(
subLocation.Id,
userId,
gameVersion,
)
// TODO: Make getDestinationCompletion do something like this.
const challenges = controller.challengeService.getChallengesForLocation(
subLocation.Id,
gameVersion,
)
const challengeCategoryCompletion: ChallengeCategoryCompletion[] = []
for (const challengeGroup in challenges) {
const challengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
{
challengeGroup: challenges[challengeGroup],
},
userId,
gameVersion,
)
challengeCategoryCompletion.push({
Name: challenges[challengeGroup][0].CategoryName,
...challengeCompletion,
})
}
const destinationCompletion = getDestinationCompletion(
parentLocation,
subLocation,
gameVersion,
userId,
)
playerProfilePage.SubLocationData.push({
ParentLocation: parentLocation,
Location: subLocation,
CompletionData: completionData,
ChallengeCategoryCompletion: challengeCategoryCompletion,
ChallengeCompletion: destinationCompletion.ChallengeCompletion,
OpportunityStatistics: destinationCompletion.OpportunityStatistics,
LocationCompletionPercent:
destinationCompletion.LocationCompletionPercent,
})
}
const userProfile = getUserData(userId, gameVersion)
playerProfilePage.PlayerProfileXp.Total =
userProfile.Extensions.progression.PlayerProfileXP.Total
playerProfilePage.PlayerProfileXp.Level =
userProfile.Extensions.progression.PlayerProfileXP.ProfileLevel
const subLocationMap = new Map(
userProfile.Extensions.progression.PlayerProfileXP.Sublocations.map(
(obj) => [obj.Location, obj],
),
)
for (const season of playerProfilePage.PlayerProfileXp.Seasons) {
for (const location of season.Locations) {
const subLocationData = subLocationMap.get(location.LocationId)
location.Xp = subLocationData?.Xp || 0
location.ActionXp = subLocationData?.ActionXp || 0
if (
location.LocationProgression &&
!isSniperLocation(location.LocationId)
) {
// We typecast below as it could be an object for subpackages.
// Checks before this ensure it isn't, but TS doesn't realise this.
location.LocationProgression.Level =
(
userProfile.Extensions.progression.Locations[
location.LocationId
] as ProgressionData
).Level || 1
}
}
}
return playerProfilePage
}

View File

@ -21,11 +21,11 @@ import { generateUserCentric } from "../contracts/dataGen"
import { controller } from "../controller"
import type {
GameVersion,
JwtData,
MissionStory,
PlayNextCampaignDetails,
UserCentricContract,
} from "../types/types"
import assert from "assert"
/**
* Main story campaign ordered mission IDs.
@ -157,6 +157,8 @@ export function createMainOpportunityTile(
false,
)
assert.ok(contractData)
return {
CategoryType: "MainOpportunity",
CategoryName: "UI_PLAYNEXT_MAINOPPORTUNITY_CATEGORY_NAME",
@ -202,7 +204,7 @@ export type GameFacingPlayNextData = {
export function getGamePlayNextData(
contractId: string,
jwt: JwtData,
userId: string,
gameVersion: GameVersion,
): GameFacingPlayNextData {
const cats: PlayNextCategory[] = []
@ -225,14 +227,9 @@ export function getGamePlayNextData(
if (shouldContinue) {
cats.push(
createPlayNextMission(
jwt.unique_name,
nextMissionId,
gameVersion,
{
CampaignName: `UI_SEASON_${nextSeasonId}`,
},
),
createPlayNextMission(userId, nextMissionId, gameVersion, {
CampaignName: `UI_SEASON_${nextSeasonId}`,
}),
)
}
@ -244,7 +241,7 @@ export function getGamePlayNextData(
if (pzIdIndex !== -1 && pzIdIndex !== orderedPZMissions.length - 1) {
const nextMissionId = orderedPZMissions[pzIdIndex + 1]
cats.push(
createPlayNextMission(jwt.unique_name, nextMissionId, gameVersion, {
createPlayNextMission(userId, nextMissionId, gameVersion, {
CampaignName: "UI_CONTRACT_CAMPAIGN_WHITE_SPIDER_TITLE",
ParentCampaignName: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE",
}),
@ -255,7 +252,7 @@ export function getGamePlayNextData(
if (contractId === "f1ba328f-e3dd-4ef8-bb26-0363499fdd95") {
const nextMissionId = "0b616e62-af0c-495b-82e3-b778e82b5912"
cats.push(
createPlayNextMission(jwt.unique_name, nextMissionId, gameVersion, {
createPlayNextMission(userId, nextMissionId, gameVersion, {
CampaignName: "UI_MENU_PAGE_SPECIAL_ASSIGNMENTS_TITLE",
ParentCampaignName: "UI_MENU_PAGE_SIDE_MISSIONS_TITLE",
}),
@ -274,7 +271,7 @@ export function getGamePlayNextData(
if (pluginData) {
if (pluginData.overrideIndex !== undefined) {
cats[pluginData.overrideIndex] = createPlayNextMission(
jwt.unique_name,
userId,
pluginData.nextContractId,
gameVersion,
pluginData.campaignDetails,
@ -282,7 +279,7 @@ export function getGamePlayNextData(
} else {
cats.push(
createPlayNextMission(
jwt.unique_name,
userId,
pluginData.nextContractId,
gameVersion,
pluginData.campaignDetails,
@ -293,6 +290,6 @@ export function getGamePlayNextData(
return {
Categories: cats,
ProfileId: jwt.unique_name,
ProfileId: userId,
}
}

View File

@ -17,8 +17,44 @@
*/
import { controller } from "../controller"
import type { GameVersion, MissionManifest } from "../types/types"
import type {
CompletionData,
GameVersion,
MissionManifest,
} from "../types/types"
import { getSubLocationByName } from "../contracts/dataGen"
import { InventoryItem } from "../inventory"
import assert from "assert"
export type SniperCharacter = {
Id: string
Loadout: SniperLoadout
CompletionData: CompletionData
}
export type SniperLoadout = {
LoadoutData: {
SlotId: string
SlotName: string
Items: {
Item: InventoryItem
ItemDetails: unknown
}[]
Page: number
Recommended: {
item: InventoryItem
type: string
owned: boolean
}
HasMore: boolean
HasMoreLeft: boolean
HasMoreRight: boolean
OptionalData: Record<never, never>
}[]
LimitedLoadoutUnlockLevel: number | undefined
}
type Return = (SniperLoadout | SniperCharacter)[]
/**
* Creates the sniper loadouts data for a contract. Returns loadouts for all three
@ -38,13 +74,15 @@ export function createSniperLoadouts(
gameVersion: GameVersion,
contractData: MissionManifest,
loadoutData = false,
) {
const sniperLoadouts = []
): Return {
const sniperLoadouts: Return = []
const parentLocation = getSubLocationByName(
contractData.Metadata.Location,
gameVersion,
)?.Properties.ParentLocation
assert.ok(parentLocation, "Parent location not found")
// This function call is used as it gets all mastery data for the current location
// which includes all the characters we'll need.
// We map it by Id for quick lookup.
@ -54,85 +92,97 @@ export function createSniperLoadouts(
.map((data) => [data.CompletionData.Id, data]),
)
if (contractData.Metadata.Type === "sniper") {
for (const charSetup of contractData.Metadata.CharacterSetup) {
for (const character of charSetup.Characters) {
// Get the mastery data for this character
const masteryData = masteryMap.get(
character.MandatoryLoadout[0],
)
if (contractData.Metadata.Type !== "sniper") {
return sniperLoadouts
}
// Get the unlockable that is currently unlocked
const curUnlockable =
masteryData.CompletionData.Level === 1
? masteryData.Unlockable
: masteryData.Drops[
masteryData.CompletionData.Level - 2
].Unlockable
assert.ok(
contractData.Metadata.CharacterSetup,
"Contract missing sniper character setup",
)
const data = {
Id: character.Id,
Loadout: {
LoadoutData: [
{
SlotId: "0",
SlotName: "carriedweapon",
Items: [
{
Item: {
InstanceId: character.Id,
ProfileId: userId,
Unlockable: curUnlockable,
Properties: {},
},
ItemDetails: {
Capabilities: [],
StatList: Object.keys(
curUnlockable.Properties
.Gameplay,
).map((key) => {
return {
Name: key,
Ratio: curUnlockable
.Properties.Gameplay[
key
],
}
}),
PropertyTexts: [],
},
},
],
Page: 0,
Recommended: {
item: {
InstanceId: character.Id,
ProfileId: userId,
Unlockable: curUnlockable,
Properties: {},
},
type: "carriedweapon",
owned: true,
for (const charSetup of contractData.Metadata.CharacterSetup) {
for (const character of charSetup.Characters) {
// Get the mastery data for this character
const masteryData = masteryMap.get(
character.MandatoryLoadout?.[0] || "",
)
assert.ok(
masteryData,
`Mastery data not found for ${contractData.Metadata.Id}`,
)
// Get the unlockable that is currently unlocked
const curUnlockable =
masteryData.CompletionData.Level === 1
? masteryData.Unlockable
: masteryData.Drops[masteryData.CompletionData.Level - 2]
.Unlockable
assert.ok(curUnlockable, "Unlockable not found")
assert.ok(
curUnlockable.Properties.Gameplay,
"Unlockable has no gameplay data",
)
const data: SniperCharacter = {
Id: character.Id,
Loadout: {
LoadoutData: [
{
SlotId: "0",
SlotName: "carriedweapon",
Items: [],
Page: 0,
Recommended: {
item: {
InstanceId: character.Id,
ProfileId: userId,
Unlockable: curUnlockable,
Properties: {},
},
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData: {},
type: "carriedweapon",
owned: true,
},
],
LimitedLoadoutUnlockLevel: 0 as number | undefined,
},
CompletionData: masteryData?.CompletionData,
}
if (loadoutData) {
delete data.Loadout.LimitedLoadoutUnlockLevel
sniperLoadouts.push(data.Loadout)
continue
}
sniperLoadouts.push(data)
HasMore: false,
HasMoreLeft: false,
HasMoreRight: false,
OptionalData: {},
},
],
LimitedLoadoutUnlockLevel: 0 as number | undefined,
},
CompletionData: masteryData.CompletionData,
}
data.Loadout.LoadoutData[0].Items.push({
Item: {
InstanceId: character.Id,
ProfileId: userId,
Unlockable: curUnlockable,
Properties: {},
},
ItemDetails: {
Capabilities: [],
StatList: Object.keys(
curUnlockable.Properties.Gameplay,
).map((key) => ({
Name: key,
// @ts-expect-error This will work.
Ratio: curUnlockable.Properties.Gameplay[key],
})),
PropertyTexts: [],
},
})
if (loadoutData) {
delete data.Loadout.LimitedLoadoutUnlockLevel
sniperLoadouts.push(data.Loadout)
continue
}
sniperLoadouts.push(data)
}
}

View File

@ -37,6 +37,7 @@ import { log, LogLevel } from "../loggingInterop"
import { getUserData } from "../databaseHandler"
import { getFlag } from "../flags"
import { loadouts } from "../loadouts"
import assert from "assert"
/**
* Algorithm to get the stashpoint items data for H2 and H3.
@ -139,10 +140,13 @@ export function getModernStashData(
const inventory = createInventory(
userId,
gameVersion,
getSubLocationByName(contractData?.Metadata.Location, gameVersion),
getSubLocationByName(
contractData?.Metadata.Location || "",
gameVersion,
),
)
if (query.slotname.endsWith(query.slotid!.toString())) {
if (query.slotname?.endsWith(query.slotid!.toString())) {
query.slotname = query.slotname.slice(
0,
-query.slotid!.toString().length,
@ -150,7 +154,7 @@ export function getModernStashData(
}
const stashData: ModernStashData = {
SlotId: query.slotid,
SlotId: query.slotid!,
LoadoutItemsData: {
SlotId: query.slotid,
Items: getModernStashItemsData(
@ -169,7 +173,7 @@ export function getModernStashData(
AllowContainers: query.allowcontainers, // ?? true
},
},
ShowSlotName: query.slotname,
ShowSlotName: query.slotname!,
}
if (contractData) {
@ -256,6 +260,10 @@ export function getLegacyStashData(
userId: string,
gameVersion: GameVersion,
) {
if (!query.contractid || !query.slotname) {
return undefined
}
const contractData = controller.resolveContract(query.contractid)
if (!contractData) {
@ -277,6 +285,8 @@ export function getLegacyStashData(
gameVersion,
)
assert.ok(sublocation, "Sublocation not found")
const inventory = createInventory(userId, gameVersion, sublocation)
const userCentricContract = generateUserCentric(
@ -297,14 +307,17 @@ export function getLegacyStashData(
const dl = userProfile.Extensions.defaultloadout
if (!dl) {
return defaultLoadout[id]
return defaultLoadout[id as keyof typeof defaultLoadout]
}
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- it makes the code 10x less readable
const forLocation = (userProfile.Extensions.defaultloadout || {})[
sublocation?.Properties?.ParentLocation
sublocation?.Properties?.ParentLocation || ""
]
return (forLocation || defaultLoadout)[id]
return (forLocation || defaultLoadout)[
id as keyof typeof defaultLoadout
]
} else {
let dl = loadouts.getLoadoutFor("h1")
@ -312,7 +325,8 @@ export function getLegacyStashData(
dl = loadouts.createDefault("h1")
}
const forLocation = dl.data[sublocation?.Properties?.ParentLocation]
const forLocation =
dl.data[sublocation?.Properties?.ParentLocation || ""]
return (forLocation || defaultLoadout)[id]
}
@ -329,7 +343,7 @@ export function getLegacyStashData(
Recommended: getLoadoutItem(slotid)
? {
item: getUnlockableById(
getLoadoutItem(slotid),
getLoadoutItem(slotid)!,
gameVersion,
),
type: loadoutSlots[slotid],
@ -348,7 +362,7 @@ export function getLegacyStashData(
}
: {},
})),
Contract: userCentricContract.Contract,
Contract: userCentricContract?.Contract,
ShowSlotName: query.slotname,
UserCentric: userCentricContract,
}
@ -398,10 +412,10 @@ export function getSafehouseCategory(
continue // I don't want to put this in that elif statement
}
let category = safehouseData.SubCategories.find(
let category = safehouseData.SubCategories?.find(
(cat) => cat.Category === item.Unlockable.Type,
)
let subcategory
let subcategory: SafehouseCategory | undefined
if (!category) {
category = {
@ -410,16 +424,16 @@ export function getSafehouseCategory(
IsLeaf: false,
Data: null,
}
safehouseData.SubCategories.push(category)
safehouseData.SubCategories?.push(category)
}
subcategory = category.SubCategories.find(
subcategory = category.SubCategories?.find(
(cat) => cat.Category === item.Unlockable.Subtype,
)
if (!subcategory) {
subcategory = {
Category: item.Unlockable.Subtype,
Category: item.Unlockable.Subtype!,
SubCategories: null,
IsLeaf: true,
Data: {
@ -430,13 +444,14 @@ export function getSafehouseCategory(
HasMore: false,
},
}
category.SubCategories.push(subcategory)
category.SubCategories?.push(subcategory!)
}
subcategory.Data?.Items.push({
subcategory!.Data?.Items.push({
Item: item,
ItemDetails: {
Capabilities: [],
// @ts-expect-error It just works. Types are probably wrong somewhere up the chain.
StatList: item.Unlockable.Properties.Gameplay
? Object.entries(item.Unlockable.Properties.Gameplay).map(
([key, value]) => ({
@ -452,15 +467,15 @@ export function getSafehouseCategory(
})
}
for (const [id, category] of safehouseData.SubCategories.entries()) {
if (category.SubCategories.length === 1) {
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
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 (safehouseData.SubCategories?.length === 1) {
// if root has only one subcategory
safehouseData = safehouseData.SubCategories[0] // flatten it
}

View File

@ -39,6 +39,7 @@ export const multiplayerMenuDataRouter = Router()
multiplayerMenuDataRouter.post(
"/multiplayermatchstatsready",
// @ts-expect-error Has JWT data.
(req: RequestWithJwt<MissionEndRequestQuery>, res) => {
res.json({
template: null,
@ -53,8 +54,11 @@ multiplayerMenuDataRouter.post(
multiplayerMenuDataRouter.post(
"/multiplayermatchstats",
// @ts-expect-error Has JWT data.
(req: RequestWithJwt<MultiplayerMatchStatsQuery>, res) => {
const sessionDetails = contractSessions.get(req.query.contractSessionId)
const sessionDetails = contractSessions.get(
req.query.contractSessionId || "",
)
if (!sessionDetails) {
// contract session not found
@ -90,16 +94,22 @@ multiplayerMenuDataRouter.post(
},
)
interface MultiplayerPresetsQuery {
type MultiplayerPresetsQuery = {
gamemode?: string
disguiseUnlockableId?: string
}
multiplayerMenuDataRouter.get(
"/multiplayerpresets",
// @ts-expect-error Has JWT data.
(req: RequestWithJwt<MultiplayerPresetsQuery>, res) => {
if (req.query.gamemode !== "versus") {
res.status(401).send("unknown gamemode")
res.status(400).send("unknown gamemode")
return
}
if (!req.query.disguiseUnlockableId) {
res.status(400).send("no disguiseUnlockableId")
return
}
@ -141,9 +151,16 @@ multiplayerMenuDataRouter.get(
multiplayerMenuDataRouter.get(
"/multiplayer",
// @ts-expect-error Has JWT data.
(req: RequestWithJwt<MultiplayerQuery>, res) => {
// /multiplayer?gamemode=versus&disguiseUnlockableId=TOKEN_OUTFIT_ELUSIVE_COMPLETE_15_SUIT
if (req.query.gamemode !== "versus") {
res.status(400).send("unknown gamemode")
return
}
if (!req.query.disguiseUnlockableId) {
res.status(400).send("no disguiseUnlockableId")
return
}

View File

@ -31,7 +31,7 @@ import { randomUUID } from "crypto"
import { getConfig } from "../configSwizzleManager"
import { generateUserCentric } from "../contracts/dataGen"
import { controller } from "../controller"
import { MatchOverC2SEvent } from "../types/events"
import { MatchOverC2SEvent, OpponentsC2sEvent } from "../types/events"
/**
* A multiplayer preset.
@ -89,6 +89,7 @@ const activeMatches: Map<string, MatchData> = new Map()
multiplayerRouter.post(
"/GetRequiredResourcesForPreset",
jsonMiddleware(),
// @ts-expect-error Has JWT data.
(req: RequestWithJwt, res) => {
const allPresets = getConfig<MultiplayerPreset[]>(
"MultiplayerPresets",
@ -114,7 +115,7 @@ multiplayerRouter.post(
req.gameVersion,
),
)
.filter(Boolean)
.filter(Boolean) as UserCentricContract[]
res.json(
userCentrics.map((userCentric: UserCentricContract) => ({
@ -132,6 +133,7 @@ multiplayerRouter.post(
multiplayerRouter.post(
"/RegisterToMatch",
jsonMiddleware(),
// @ts-expect-error Has JWT data.
(req: RequestWithJwt, res) => {
// get a random contract from the list of possible ones in the selected preset
const multiplayerPresets = getConfig<MultiplayerPreset[]>(
@ -212,6 +214,7 @@ multiplayerRouter.post(
multiplayerRouter.post(
"/SetMatchData",
jsonMiddleware(),
// @ts-expect-error Has JWT data.
(req: RequestWithJwt, res) => {
const match = activeMatches.get(req.body.matchId)
@ -256,9 +259,7 @@ export function handleMultiplayerEvent(
ghost.unnoticedKills += 1
return true
case "Opponents": {
const value = event.Value as {
ConnectedSessions: string[]
}
const value = (event as OpponentsC2sEvent).Value
ghost.Opponents = value.ConnectedSessions
return true

View File

@ -16,7 +16,6 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Response } from "express"
import { decode, sign } from "jsonwebtoken"
import { extractToken, uuidRegex } from "./utils"
import type { GameVersion, RequestWithJwt, UserProfile } from "./types/types"
@ -49,10 +48,37 @@ export const JWT_SECRET = PEACOCK_DEV
? "secret"
: randomBytes(32).toString("hex")
export async function handleOauthToken(
req: RequestWithJwt,
res: Response,
): Promise<void> {
export type OAuthTokenBody = {
grant_type: "external_steam" | "external_epic" | "refresh_token"
steam_userid?: string
epic_userid?: string
access_token: string
pId?: string
locale: string
rgn: string
gs: string
steam_appid: string
}
export type OAuthTokenResponse = {
access_token: string
token_type: "bearer" | string
expires_in: number
refresh_token: string
}
export const error400: unique symbol = Symbol("http400")
export const error406: unique symbol = Symbol("http406")
/**
* This is the code that handles the OAuth token request.
* We cannot do this without a request object because of the refresh token use case.
*
* @param req The request object.
*/
export async function handleOAuthToken(
req: RequestWithJwt<never, OAuthTokenBody>,
): Promise<typeof error400 | typeof error406 | OAuthTokenResponse> {
const isFrankenstein = req.body.gs === "scpc-prod"
const signOptions = {
@ -69,19 +95,17 @@ export async function handleOauthToken(
external_appid: string
if (req.body.grant_type === "external_steam") {
if (!/^\d{1,20}$/.test(req.body.steam_userid)) {
res.status(400).end() // invalid steam user id
return
if (!/^\d{1,20}$/.test(req.body.steam_userid || "")) {
return error400 // invalid steam user id
}
external_platform = "steam"
external_userid = req.body.steam_userid
external_userid = req.body.steam_userid || ""
external_users_folder = "steamids"
external_appid = req.body.steam_appid
} else if (req.body.grant_type === "external_epic") {
if (!/^[\da-f]{32}$/.test(req.body.epic_userid)) {
res.status(400).end() // invalid epic user id
return
if (!/^[\da-f]{32}$/.test(req.body.epic_userid || "")) {
return error400 // invalid epic user id
}
const epic_token = decode(
@ -92,25 +116,24 @@ export async function handleOauthToken(
}
if (!epic_token || !(epic_token.appid || epic_token.app)) {
res.status(400).end() // invalid epic access token
return
return error400 // invalid epic access token
}
external_appid = epic_token.appid || epic_token.app
external_platform = "epic"
external_userid = req.body.epic_userid
external_userid = req.body.epic_userid || ""
external_users_folder = "epicids"
} else if (req.body.grant_type === "refresh_token") {
// send back the token from the request (re-signed so the timestamps update)
extractToken(req) // init req.jwt
// remove signOptions from existing jwt
// ts-expect-error Non-optional, we're reassigning.
// @ts-expect-error Non-optional, we're reassigning.
delete req.jwt.nbf // notBefore
// ts-expect-error Non-optional, we're reassigning.
// @ts-expect-error Non-optional, we're reassigning.
delete req.jwt.exp // expiresIn
// ts-expect-error Non-optional, we're reassigning.
// @ts-expect-error Non-optional, we're reassigning.
delete req.jwt.iss // issuer
// ts-expect-error Non-optional, we're reassigning.
// @ts-expect-error Non-optional, we're reassigning.
delete req.jwt.aud // audience
if (!isFrankenstein) {
@ -126,35 +149,33 @@ export async function handleOauthToken(
}
}
res.json({
return {
access_token: sign(req.jwt, JWT_SECRET, signOptions),
token_type: "bearer",
expires_in: 5000,
refresh_token: randomUUID(),
})
return
}
} else {
res.status(406).end() // unsupported auth method
return
return error406 // unsupported auth method
}
if (req.body.pId && !uuidRegex.test(req.body.pId)) {
res.status(400).end() // pId is not a GUID
return
return error406 // pId is not a GUID
}
const isHitman3 =
external_appid === "fghi4567xQOCheZIin0pazB47qGUvZw4" ||
external_appid === STEAM_NAMESPACE_2021
const gameVersion: GameVersion = isFrankenstein
? "scpc"
: isHitman3
? "h3"
: external_appid === STEAM_NAMESPACE_2018
? "h2"
: "h1"
let gameVersion: GameVersion = "h1"
if (isFrankenstein) {
gameVersion = "scpc"
} else if (isHitman3) {
gameVersion = "h3"
} else if (external_appid === STEAM_NAMESPACE_2018) {
gameVersion = "h2"
}
if (!req.body.pId) {
// if no profile id supplied
@ -184,7 +205,8 @@ export async function handleOauthToken(
await writeExternalUserData(
external_userid,
external_users_folder,
req.body.pId,
// we've already confirmed this will be there, and it's a GUID
req.body.pId!,
gameVersion,
)
})
@ -227,9 +249,9 @@ export async function handleOauthToken(
userData.LinkedAccounts[external_platform] = external_userid
if (external_platform === "steam") {
userData.SteamId = req.body.steam_userid
userData.SteamId = req.body.steam_userid!
} else if (external_platform === "epic") {
userData.EpicId = req.body.epic_userid
userData.EpicId = req.body.epic_userid!
}
if (Object.hasOwn(userData.Extensions, "inventory")) {
@ -262,13 +284,13 @@ export async function handleOauthToken(
if (external_platform === "epic") {
return await new EpicH3Strategy().get(
req.body.access_token,
req.body.epic_userid,
req.body.epic_userid!,
)
} else if (external_platform === "steam") {
return await new IOIStrategy(
gameVersion,
STEAM_NAMESPACE_2021,
).get(req.body.pId)
).get(req.body.pId!)
} else {
log(LogLevel.ERROR, "Unsupported platform.")
return []
@ -316,10 +338,10 @@ export async function handleOauthToken(
clearInventoryFor(req.body.pId)
res!.json({
return {
access_token: sign(userinfo, JWT_SECRET, signOptions),
token_type: "bearer",
expires_in: 5000,
refresh_token: randomUUID(),
})
}
}

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import axios, { AxiosResponse } from "axios"
import axios, { AxiosError, AxiosResponse } from "axios"
import type { Request } from "express"
import { log, LogLevel } from "./loggingInterop"
import { handleAxiosError } from "./utils"
@ -106,7 +106,7 @@ export class OfficialServerAuth {
this._refreshToken = r.refresh_token
this.initialized = true
} catch (e) {
handleAxiosError(e)
handleAxiosError(e as AxiosError)
if (PEACOCK_DEV) {
log(

View File

@ -42,7 +42,7 @@ export function calculatePlaystyle(
const doneKillMethods: string[] = []
const doneAccidents: string[] = []
session.kills.forEach((k) => {
session.kills?.forEach((k) => {
if (k.KillClass === "ballistic") {
if (k.KillItemCategory === "pistol") {
playstylesCopy[1].Score += 6000

View File

@ -29,7 +29,8 @@ import {
import { json as jsonMiddleware } from "body-parser"
import { getPlatformEntitlements } from "./platformEntitlements"
import { contractSessions, newSession } from "./eventHandler"
import type {
import {
ChallengeProgressionData,
CompiledChallengeIngameData,
ContractSession,
GameVersion,
@ -57,7 +58,8 @@ import {
compileRuntimeChallenge,
inclusionDataCheck,
} from "./candle/challengeHelpers"
import { LoadSaveBody } from "./types/gameSchemas"
import { LoadSaveBody, ResolveGamerTagsBody } from "./types/gameSchemas"
import assert from "assert"
const profileRouter = Router()
@ -109,8 +111,9 @@ export const fakePlayerRegistry: {
profileRouter.post(
"/AuthenticationService/GetBlobOfflineCacheDatabaseDiff",
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
const configs = []
const configs: string[] = []
menuSystemDatabase.hooks.getDatabaseDiff.call(configs, req.gameVersion)
@ -118,19 +121,21 @@ profileRouter.post(
},
)
profileRouter.post("/ProfileService/SetClientEntitlements", (req, res) => {
profileRouter.post("/ProfileService/SetClientEntitlements", (_, res) => {
res.json("null")
})
profileRouter.post(
"/ProfileService/GetPlatformEntitlements",
jsonMiddleware(),
// @ts-expect-error Jwt props.
getPlatformEntitlements,
)
profileRouter.post(
"/ProfileService/UpdateProfileStats",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.id) {
return res.status(403).end() // data submitted for different profile id
@ -148,18 +153,19 @@ profileRouter.post(
profileRouter.post(
"/ProfileService/SynchronizeOfflineUnlockables",
(req, res) => {
(_, res) => {
res.status(204).end()
},
)
profileRouter.post("/ProfileService/GetUserConfig", (req, res) => {
profileRouter.post("/ProfileService/GetUserConfig", (_, res) => {
res.json({})
})
profileRouter.post(
"/ProfileService/GetProfile",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
if (req.body.id !== req.jwt.unique_name) {
res.status(403).end() // data requested for different profile id
@ -173,7 +179,10 @@ profileRouter.post(
const userdata = getUserData(req.jwt.unique_name, req.gameVersion)
const extensions = req.body.extensions.reduce(
(acc: object, key: string) => {
if (Object.hasOwn(userdata.Extensions, key)) {
if (
userdata.Extensions[key as keyof typeof userdata.Extensions]
) {
// @ts-expect-error Ok.
acc[key] = userdata.Extensions[key]
}
@ -188,6 +197,7 @@ profileRouter.post(
profileRouter.post(
"/UnlockableService/GetInventory",
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
res.json(createInventory(req.jwt.unique_name, req.gameVersion))
},
@ -196,6 +206,7 @@ profileRouter.post(
profileRouter.post(
"/ProfileService/UpdateExtensions",
jsonMiddleware(),
// @ts-expect-error jwt props.
(
req: RequestWithJwt<
Record<string, never>,
@ -213,6 +224,7 @@ profileRouter.post(
for (const extension in req.body.extensionsData) {
if (Object.hasOwn(req.body.extensionsData, extension)) {
// @ts-expect-error It's fine.
userdata.Extensions[extension] =
req.body.extensionsData[extension]
}
@ -226,6 +238,7 @@ profileRouter.post(
profileRouter.post(
"/ProfileService/SynchroniseGameStats",
jsonMiddleware(),
// @ts-expect-error jwt props.
(req: RequestWithJwt, res) => {
if (req.body.profileId !== req.jwt.unique_name) {
// data requested for different profile id
@ -282,32 +295,6 @@ export async function resolveProfiles(
})
}
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,
Version: LATEST_PROFILE_VERSION,
})
}
const fakePlayer = fakePlayerRegistry.getFromId(id)
if (fakePlayer) {
@ -350,6 +337,7 @@ export async function resolveProfiles(
}),
)
)
// @ts-expect-error This whole function is an exception handling clusterfunk and needs to be rewritten.
.map((outcome: PromiseSettledResult<UserProfile>) => {
if (outcome.status !== "fulfilled") {
if (outcome.reason.code === "ENOENT") {
@ -387,6 +375,7 @@ export async function resolveProfiles(
profileRouter.post(
"/ProfileService/ResolveProfiles",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
async (req: RequestWithJwt, res) => {
res.json(await resolveProfiles(req.body.profileIDs, req.gameVersion))
},
@ -395,16 +384,22 @@ profileRouter.post(
profileRouter.post(
"/ProfileService/ResolveGamerTags",
jsonMiddleware(),
async (req: RequestWithJwt, res) => {
// @ts-expect-error Has jwt props.
async (req: RequestWithJwt<never, ResolveGamerTagsBody>, res) => {
if (!Array.isArray(req.body.profileIds)) {
res.status(400).send("bad body")
return
}
const profiles = (await resolveProfiles(
req.body.profileIds,
req.gameVersion,
)) as UserProfile[]
const result = {
steam: {},
epic: {},
dev: {},
steam: {} as Record<string, string>,
epic: {} as Record<string, string>,
dev: {} as Record<string, string>,
}
for (const profile of profiles) {
@ -427,26 +422,27 @@ profileRouter.post(
},
)
profileRouter.post("/ProfileService/GetFriendsCount", (req, res) =>
res.send("0"),
)
profileRouter.post("/ProfileService/GetFriendsCount", (_, res) => res.send("0"))
profileRouter.post(
"/GamePersistentDataService/GetData",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(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])
type Cast = keyof typeof userdata.Extensions.gamepersistentdata
res.json(userdata.Extensions.gamepersistentdata[req.body.key as Cast])
},
)
profileRouter.post(
"/GamePersistentDataService/SaveData",
jsonMiddleware(),
// @ts-expect-error jwt props.
(req: RequestWithJwt, res) => {
if (req.jwt.unique_name !== req.body.userId) {
return res.status(403).end()
@ -454,6 +450,7 @@ profileRouter.post(
const userdata = getUserData(req.body.userId, req.gameVersion)
// @ts-expect-error This is fine.
userdata.Extensions.gamepersistentdata[req.body.key] = req.body.data
writeUserData(req.body.userId, req.gameVersion)
@ -464,6 +461,7 @@ profileRouter.post(
profileRouter.post(
"/ChallengesService/GetActiveChallengesAndProgression",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(
req: RequestWithJwt<
Record<string, never>,
@ -489,11 +487,14 @@ profileRouter.post(
return res.json([])
}
let challenges = getVersionedConfig<CompiledChallengeIngameData[]>(
"GlobalChallenges",
req.gameVersion,
true,
)
type CWP = {
Challenge: CompiledChallengeIngameData
Progression: ChallengeProgressionData | undefined
}
let challenges: CWP[] = getVersionedConfig<
CompiledChallengeIngameData[]
>("GlobalChallenges", req.gameVersion, true)
.filter((val) => inclusionDataCheck(val.InclusionData, json))
.map((item) => ({ Challenge: item, Progression: undefined }))
@ -528,6 +529,7 @@ profileRouter.post(
challenges.forEach((val) => {
// prettier-ignore
if (val.Challenge.Id === "b1a85feb-55af-4707-8271-b3522661c0b1") {
// @ts-expect-error State machines impossible to type.
// prettier-ignore
val.Challenge.Definition!["States"]["Start"][
"CrowdNPC_Died"
@ -580,6 +582,7 @@ profileRouter.post(
profileRouter.post(
"/HubPagesService/GetChallengeTreeFor",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
(req: RequestWithJwt, res) => {
res.json({
Data: {
@ -607,6 +610,7 @@ profileRouter.post(
profileRouter.post(
"/DefaultLoadoutService/Set",
jsonMiddleware(),
// @ts-expect-error jwt props.
async (req: RequestWithJwt, res) => {
if (getFlag("loadoutSaving") === "PROFILES") {
let loadout = loadouts.getLoadoutFor(req.gameVersion)
@ -638,6 +642,7 @@ profileRouter.post(
profileRouter.post(
"/ProfileService/UpdateUserSaveFileTable",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
async (req: RequestWithJwt<never, UpdateUserSaveFileTableBody>, res) => {
if (req.body.clientSaveFileList.length > 0) {
// We are saving to the SaveFile with the most recent timestamp.
@ -681,7 +686,7 @@ profileRouter.post(
},
)
function getErrorMessage(error: unknown) {
export function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
@ -691,7 +696,82 @@ function getErrorCause(error: unknown) {
return String(error)
}
async function saveSession(
profileRouter.post(
"/ContractSessionsService/Load",
jsonMiddleware(),
// @ts-expect-error Has jwt props.
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 as Error)?.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}"`)
},
)
profileRouter.post(
"/ProfileService/GetSemLinkStatus",
jsonMiddleware(),
(_, res) => {
res.json({
IsConfirmed: true,
LinkedEmail: "mail@example.com",
IOIAccountId: nilUuid,
IOIAccountBaseUrl: "https://account.ioi.dk",
})
},
)
export async function saveSession(
save: SaveFile,
userData: UserProfile,
): Promise<void> {
@ -747,69 +827,12 @@ async function saveSession(
log(
LogLevel.DEBUG,
`Saved contract to slot ${slot} with token = ${token}, session id = ${sessionId}, start time = ${
contractSessions.get(sessionId).timerStart
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(
export async function loadSession(
sessionId: string,
token: string,
userData: UserProfile,
@ -831,6 +854,8 @@ async function loadSession(
}
}
assert.ok(sessionData, "should have session data")
// 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!
@ -846,6 +871,11 @@ async function loadSession(
sessionData.gameVersion,
)
assert.ok(
challenge,
`session has context for unregistered challenge ${cid}`,
)
if (
!userData.Extensions.ChallengeProgression[cid].Completed &&
controller.challengeService.needSaveProgression(challenge)
@ -859,22 +889,9 @@ async function loadSession(
log(
LogLevel.DEBUG,
`Loaded contract with token = ${token}, session id = ${sessionId}, start time = ${
contractSessions.get(sessionId).timerStart
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 }

View File

@ -48,7 +48,7 @@ import {
getLevelCount,
} from "./contracts/escalations/escalationService"
import { getUserData, writeUserData } from "./databaseHandler"
import axios from "axios"
import axios, { AxiosError } from "axios"
import { getFlag } from "./flags"
import { log, LogLevel } from "./loggingInterop"
import {
@ -64,6 +64,7 @@ import {
CalculateScoreResult,
CalculateSniperScoreResult,
CalculateXpResult,
ContractScore,
MissionEndChallenge,
MissionEndDrop,
MissionEndEvergreen,
@ -72,6 +73,7 @@ import {
import { MasteryData } from "./types/mastery"
import { createInventory, InventoryItem, getUnlockablesById } from "./inventory"
import { calculatePlaystyle } from "./playStyles"
import assert from "assert"
export function calculateGlobalXp(
contractSession: ContractSession,
@ -81,8 +83,10 @@ export function calculateGlobalXp(
let totalXp = 0
// TODO: Merge with the non-global challenges?
for (const challengeId of Object.keys(contractSession.challengeContexts)) {
const data = contractSession.challengeContexts[challengeId]
for (const challengeId of Object.keys(
contractSession.challengeContexts || {},
)) {
const data = contractSession.challengeContexts![challengeId]
if (data.timesCompleted <= 0) {
continue
@ -93,7 +97,7 @@ export function calculateGlobalXp(
gameVersion,
)
if (!challenge || !challenge.Xp || !challenge.Tags.includes("global")) {
if (!challenge?.Xp || !challenge.Tags.includes("global")) {
continue
}
@ -181,7 +185,7 @@ export function calculateScore(
headline: "UI_SCORING_SUMMARY_NO_BODIES_FOUND",
bonusId: "NoBodiesFound",
condition:
contractSession.legacyHasBodyBeenFound === false &&
!contractSession.legacyHasBodyBeenFound &&
[...contractSession.bodiesFoundBy].every(
(witness) =>
(gameVersion === "h1"
@ -386,7 +390,8 @@ export function calculateSniperScore(
[480, 35000], // 35000 bonus score at 480 secs (8 min)
[900, 0], // 0 bonus score at 900 secs (15 min)
]
let prevsecs: number, prevscore: number
let prevsecs: number = 0
let prevscore: number = 0
for (const [secs, score] of scorePoints) {
if (bonusTimeTotal > secs) {
@ -414,36 +419,46 @@ export function calculateSniperScore(
scoreTotal: 0,
}
const baseScore = contractSession.scoring.Context["TotalScore"]
const challengeMultiplier = contractSession.scoring.Settings["challenges"][
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const baseScore = (contractSession.scoring?.Context as any)["TotalScore"]
// @ts-expect-error it's a number
const challengeMultiplier = contractSession.scoring?.Settings["challenges"][
"Unlockables"
].reduce((acc, unlockable) => {
const item = inventory.find((item) => item.Unlockable.Id === unlockable)
if (item) {
// @ts-expect-error it's a number
return acc + item.Unlockable.Properties["Multiplier"]
}
return acc
}, 1.0)
assert(
typeof challengeMultiplier === "number",
"challengeMultiplier is falsey/NaN",
)
const bulletsMissed = 0 // TODO? not sure if neccessary, the penalty is always 0 for inbuilt contracts
const bulletsMissedPenalty =
bulletsMissed *
contractSession.scoring.Settings["bulletsused"]["penalty"]
(contractSession.scoring?.Settings["bulletsused"]["penalty"] || 0)
// Get SA status from global SA challenge for contracttype sniper
const silentAssassin =
contractSession.challengeContexts[
contractSession.challengeContexts?.[
"029c4971-0ddd-47ab-a568-17b007eec04e"
].state !== "Failure"
const saBonus = silentAssassin
? contractSession.scoring.Settings["silentassassin"]["score"]
? contractSession.scoring?.Settings["silentassassin"]["score"]
: 0
const saMultiplier = silentAssassin
? contractSession.scoring.Settings["silentassassin"]["multiplier"]
? contractSession.scoring?.Settings["silentassassin"]["multiplier"]
: 1.0
const subTotalScore = baseScore + timeBonus + saBonus - bulletsMissedPenalty
const totalScore = Math.round(
// @ts-expect-error it's a number
subTotalScore * challengeMultiplier * saMultiplier,
)
@ -518,7 +533,7 @@ export async function getMissionEndData(
gameVersion: GameVersion,
): Promise<MissionEndError | MissionEndResult> {
// TODO: For this entire function, add support for 2016 difficulties
const sessionDetails = contractSessions.get(query.contractSessionId)
const sessionDetails = contractSessions.get(query.contractSessionId || "")
if (!sessionDetails) {
return {
@ -625,6 +640,8 @@ export async function getMissionEndData(
false,
)
assert.ok(levelData, "contract not found")
// Resolve the id of the parent location
const subLocation = getSubLocationByName(
levelData.Metadata.Location,
@ -777,9 +794,9 @@ export async function getMissionEndData(
if (masteryData) {
maxLevel =
(query.masteryUnlockableId
? masteryData.SubPackages.find(
? masteryData.SubPackages?.find(
(subPkg) => subPkg.Id === query.masteryUnlockableId,
).MaxLevel
)?.MaxLevel
: masteryData.MaxLevel) || DEFAULT_MASTERY_MAXLEVEL
locationLevelInfo = Array.from({ length: maxLevel }, (_, i) => {
@ -828,7 +845,8 @@ export async function getMissionEndData(
)
// Evergreen
const evergreenData: MissionEndEvergreen = <MissionEndEvergreen>{
const evergreenData: MissionEndEvergreen = {
Payout: 0,
PayoutsCompleted: [],
PayoutsFailed: [],
}
@ -846,14 +864,14 @@ export async function getMissionEndData(
Object.keys(gameChangerProperties).forEach((e) => {
const gameChanger = gameChangerProperties[e]
const conditionObjective = gameChanger.Objectives.find(
const conditionObjective = gameChanger.Objectives?.find(
(e) => e.Category === "condition",
)
const secondaryObjective = gameChanger.Objectives.find(
const secondaryObjective = gameChanger.Objectives?.find(
(e) =>
e.Category === "secondary" &&
e.Definition.Context["MyPayout"],
e.Definition?.Context?.["MyPayout"],
)
if (
@ -862,18 +880,20 @@ export async function getMissionEndData(
sessionDetails.objectiveStates.get(conditionObjective.Id) ===
"Success"
) {
type P = { MyPayout: string }
const context = sessionDetails.objectiveContexts.get(
secondaryObjective.Id,
) as P | undefined
const payoutObjective = {
Name: gameChanger.Name,
Payout: parseInt(
sessionDetails.objectiveContexts.get(
secondaryObjective.Id,
)["MyPayout"] || 0,
),
Payout: parseInt(context?.["MyPayout"] || "0"),
IsPrestige: gameChanger.IsPrestigeObjective || false,
}
if (
!sessionDetails.evergreen.failed &&
!sessionDetails.evergreen?.failed &&
sessionDetails.objectiveStates.get(
secondaryObjective.Id,
) === "Success"
@ -888,7 +908,7 @@ export async function getMissionEndData(
evergreenData.Payout = totalPayout
evergreenData.EndStateEventName =
sessionDetails.evergreen.scoringScreenEndState
sessionDetails.evergreen?.scoringScreenEndState
locationLevelInfo = EVERGREEN_LEVEL_INFO
@ -909,14 +929,14 @@ export async function getMissionEndData(
calculateScoreResult.silentAssassin = false
// Overide the calculated score
calculateScoreResult.stars = undefined
calculateScoreResult.stars = 0
}
// Sniper
let unlockableProgression = undefined
let sniperChallengeScore = undefined
let sniperChallengeScore: CalculateSniperScoreResult | undefined = undefined
let contractScore = {
let contractScore: ContractScore | undefined = {
Total: calculateScoreResult.scoreWithBonus,
AchievedMasteries: calculateScoreResult.achievedMasteries,
AwardedBonuses: calculateScoreResult.awardedBonuses,
@ -969,7 +989,7 @@ export async function getMissionEndData(
Id: completionData.Id,
Level: completionData.Level,
LevelInfo: locationLevelInfo,
Name: completionData.Name,
Name: completionData.Name!,
XP: completionData.XP,
XPGain:
completionData.Level === completionData.MaxLevel
@ -977,8 +997,9 @@ export async function getMissionEndData(
: sniperScore.FinalScore,
}
// @ts-expect-error should be fine (allegedly)
userData.Extensions.progression.Locations[locationParentId][
query.masteryUnlockableId
query.masteryUnlockableId!
].PreviouslySeenXp = completionData.XP
writeUserData(jwt.unique_name, gameVersion)
@ -996,7 +1017,7 @@ export async function getMissionEndData(
// Override the playstyle
playstyle = undefined
calculateScoreResult.stars = undefined
calculateScoreResult.stars = 0
calculateScoreResult.scoringHeadlines = headlines
}
@ -1009,7 +1030,7 @@ export async function getMissionEndData(
const masteryData =
controller.masteryService.getMasteryDataForSubPackage(
locationParentId,
query.masteryUnlockableId ?? undefined,
query.masteryUnlockableId!,
gameVersion,
jwt.unique_name,
) as MasteryData
@ -1018,31 +1039,39 @@ export async function getMissionEndData(
masteryDrops = masteryData.Drops.filter(
(e) =>
e.Level > oldLocationLevel && e.Level <= newLocationLevel,
).map((e) => {
return {
Unlockable: e.Unlockable,
}
})
).map((e) => ({
Unlockable: e.Unlockable,
}))
}
}
// Challenge Drops
const challengeDrops: MissionEndDrop[] =
calculateXpResult.completedChallenges.reduce((acc, challenge) => {
if (challenge?.Drops?.length) {
const drops = getUnlockablesById(challenge.Drops, gameVersion)
delete challenge.Drops
calculateXpResult.completedChallenges.reduce(
(acc: MissionEndDrop[], challenge) => {
if (challenge?.Drops?.length) {
const drops = getUnlockablesById(
challenge.Drops,
gameVersion,
)
delete challenge.Drops
for (const drop of drops) {
acc.push({
Unlockable: drop,
SourceChallenge: challenge,
})
for (const drop of drops) {
if (!drop) {
continue
}
acc.push({
Unlockable: drop,
SourceChallenge: challenge,
})
}
}
}
return acc
}, [])
return acc
},
[],
)
// Setup the result
const result: MissionEndResult = {
@ -1094,7 +1123,7 @@ export async function getMissionEndData(
SniperChallengeScore: sniperChallengeScore,
SilentAssassin:
contractScore?.SilentAssassin ||
sniperChallengeScore?.silentAssassin ||
sniperChallengeScore?.SilentAssassin ||
false,
// TODO: Use data from the leaderboard?
NewRank: 1,
@ -1112,7 +1141,7 @@ export async function getMissionEndData(
}
// Finalize the response
if ((getFlag("autoSplitterForceSilentAssassin") as boolean) === true) {
if (getFlag("autoSplitterForceSilentAssassin")) {
if (result.ScoreOverview.SilentAssassin) {
await liveSplitManager.completeMission(timeTotal)
} else {
@ -1124,7 +1153,7 @@ export async function getMissionEndData(
if (
getFlag("leaderboards") === true &&
sessionDetails.compat === true &&
sessionDetails.compat &&
contractData.Metadata.Type !== "vsrace" &&
contractData.Metadata.Type !== "evergreen" &&
// Disable sending sniper scores for now
@ -1187,7 +1216,7 @@ export async function getMissionEndData(
},
)
} catch (e) {
handleAxiosError(e)
handleAxiosError(e as AxiosError)
log(
LogLevel.WARN,
"Failed to commit leaderboards data! Either you or the server may be offline.",

View File

@ -120,7 +120,9 @@ export class SMFSupport {
const id = contractData.Metadata.Id
const placeBefore = contractData.SMF?.destinations.placeBefore
const placeAfter = contractData.SMF?.destinations.placeAfter
// @ts-expect-error I know what I'm doing.
const inLocation = (this.controller.missionsInLocations[location] ??
// @ts-expect-error I know what I'm doing.
(this.controller.missionsInLocations[location] = [])) as string[]
if (placeBefore) {

View File

@ -165,7 +165,6 @@ export function parseContextListeners(
info.challengeCountData.total = test(total, context)
// Might be counting finished challenges, so need required challenges list. e.g. (SA5, SA12, SA17)
// todo: maybe not hard-code this?
if ((count as string).includes("CompletedChallenges")) {
info.challengeTreeIds.push(
...test("$.RequiredChallenges", context),

View File

@ -42,7 +42,9 @@ export interface IContractCreationPayload {
* The target creator API.
*/
export class TargetCreator {
// @ts-expect-error TODO: type this
private _targetSm
// @ts-expect-error TODO: type this
private _outfitSm
private _targetConds: undefined | unknown[] = undefined

View File

@ -23,6 +23,7 @@ import {
InclusionData,
MissionManifestObjective,
} from "./types"
import { gameDifficulty } from "../utils"
export interface SavedChallenge {
Id: string
@ -45,7 +46,7 @@ export interface SavedChallenge {
RuntimeType: "Hit" | string
Xp: number
XpModifier?: unknown
DifficultyLevels: string[]
DifficultyLevels: (keyof typeof gameDifficulty)[]
Definition: MissionManifestObjective["Definition"] & {
Scope: ContextScopedStorageLocation
Repeatable?: {
@ -93,7 +94,7 @@ export type ProfileChallengeData = {
export type ChallengeContext = {
context: unknown
state: string
state: string | undefined
timers: Timer[]
timesCompleted: number
}

View File

@ -257,3 +257,7 @@ export type Dart_HitC2SEvent = ClientToServerEvent<{
export type Evergreen_Payout_DataC2SEvent = ClientToServerEvent<{
Total_Payout: number
}>
export type OpponentsC2sEvent = ClientToServerEvent<{
ConnectedSessions: string[]
}>

View File

@ -32,7 +32,7 @@ export type StashpointSlotName =
| string
/**
* Query that the game sends for the stashpoint route.
* Query for `/profiles/page/stashpoint`.
*/
export type StashpointQuery = Partial<{
contractid: string
@ -47,7 +47,7 @@ export type StashpointQuery = Partial<{
}>
/**
* Query that the game sends for the stashpoint route in H2016.
* Query for `/profiles/page/stashpoint` (H2016 ONLY).
*
* @see StashpointQuery
*/
@ -94,8 +94,7 @@ export type GetCompletionDataForLocationQuery = Partial<{
}>
/**
* Body that the game sends for the
* `/authentication/api/userchannel/ContractSessionsService/Load` route.
* Body for `/authentication/api/userchannel/ContractSessionsService/Load`.
*/
export type LoadSaveBody = Partial<{
saveToken: string
@ -106,7 +105,7 @@ export type LoadSaveBody = Partial<{
}>
/**
* Query params that `/profiles/page/Safehouse` gets.
* Query for `/profiles/page/Safehouse`.
* Roughly the same as {@link SafehouseCategoryQuery} but this route is only for H1.
*/
export type SafehouseQuery = {
@ -114,7 +113,7 @@ export type SafehouseQuery = {
}
/**
* Query params that `/profiles/page/SafehouseCategory` (used for Career > Inventory and possibly some of the H1 stuff) gets.
* Query for `/profiles/page/SafehouseCategory` (used for Career > Inventory and possibly some of the H1 stuff).
*/
export type SafehouseCategoryQuery = {
type?: string
@ -122,7 +121,7 @@ export type SafehouseCategoryQuery = {
}
/**
* Query params that `/profiles/page/Destination` gets.
* Query for `/profiles/page/Destination`.
*/
export type GetDestinationQuery = {
locationId: string
@ -138,7 +137,7 @@ export type LeaderboardEntriesCommonQuery = {
}
/**
* Query params that `/profiles/page/DebriefingLeaderboards` gets.
* Query for `/profiles/page/DebriefingLeaderboards`.
* Because ofc it's different. Thanks IOI.
*/
export type DebriefingLeaderboardsQuery = {
@ -147,8 +146,37 @@ export type DebriefingLeaderboardsQuery = {
}
/**
* Query params that `/profiles/page/ChallengeLocation` gets.
* Query for `/profiles/page/ChallengeLocation`.
*/
export type ChallengeLocationQuery = {
locationId: string
}
/**
* Body for `/authentication/api/userchannel/ReportingService/ReportContract`.
*/
export type ContractReportBody = {
contractId: string
reason: number
}
/**
* Query for `/profiles/page/LookupContractPublicId`.
*/
export type LookupContractPublicIdQuery = {
publicid: string
}
/**
* Body for `/authentication/api/userchannel/ProfileService/ResolveGamerTags`.
*/
export type ResolveGamerTagsBody = {
profileIds: string[]
}
/**
* Query for `/profiles/page/GetMasteryCompletionDataForUnlockable`.
*/
export type GetMasteryCompletionDataForUnlockableQuery = {
unlockableId: string
}

View File

@ -18,12 +18,9 @@
import { CompletionData, GameVersion, Unlockable } from "./types"
export interface MasteryDataTemplate {
template: unknown
data: {
Location: Unlockable
MasteryData: MasteryData[]
}
export interface LocationMasteryData {
Location: Unlockable
MasteryData: MasteryData[]
}
export interface MasteryPackageDrop {
@ -41,19 +38,27 @@ interface MasterySubPackage {
* @since v7.0.0
* The Id field has been renamed to LocationId to properly reflect what it is.
*
* Mastery packages may have Drops OR SubPackages, never the two.
* Mastery packages may have Drops OR SubPackages, never both.
* This is to properly support sniper mastery by integrating it into the current system
* and mastery on H2016 as it is separated by difficulty.
*
* Also, a GameVersions array has been added to support multi-version mastery.
*/
export interface MasteryPackage {
export type MasteryPackage = {
LocationId: string
GameVersions: GameVersion[]
MaxLevel?: number
HideProgression?: boolean
Drops?: MasteryPackageDrop[]
SubPackages?: MasterySubPackage[]
} & (HasDrop | HasSubPackage)
type HasDrop = {
Drops: MasteryPackageDrop[]
SubPackages?: never
}
type HasSubPackage = {
Drops?: never
SubPackages: MasterySubPackage[]
}
export interface MasteryData {

View File

@ -25,12 +25,31 @@ import {
Unlockable,
} from "./types"
export interface CalculateXpResult {
export type CalculateXpResult = {
completedChallenges: MissionEndChallenge[]
xp: number
}
export interface CalculateScoreResult {
export type ScoreProgressionStats = {
LevelInfo: number[]
XP: number
Level: number
XPGain: number
Id?: string
Name?: string
Completion?: number
HideProgression?: boolean
}
export type ScoreProfileProgressionStats = {
LevelInfo: number[]
LevelInfoOffset: number
XP: number
Level: number
XPGain: number
}
export type CalculateScoreResult = {
stars: number
scoringHeadlines: ScoringHeadline[]
awardedBonuses: ScoringBonus[]
@ -41,7 +60,7 @@ export interface CalculateScoreResult {
scoreWithBonus: number
}
export interface CalculateSniperScoreResult {
export type CalculateSniperScoreResult = {
FinalScore: number
BaseScore: number
TotalChallengeMultiplier: number
@ -50,11 +69,11 @@ export interface CalculateSniperScoreResult {
TimeTaken: number
TimeBonus: number
SilentAssassin: boolean
SilentAssassinBonus: number
SilentAssassinMultiplier: number
SilentAssassinBonus: number | undefined
SilentAssassinMultiplier: number | undefined
}
export interface MissionEndChallenge {
export type MissionEndChallenge = {
ChallengeId: string
ChallengeTags: string[]
ChallengeName: string
@ -66,7 +85,7 @@ export interface MissionEndChallenge {
Drops?: string[]
}
export interface MissionEndSourceChallenge {
export type MissionEndSourceChallenge = {
ChallengeId: string
ChallengeTags: string[]
ChallengeName: string
@ -77,12 +96,12 @@ export interface MissionEndSourceChallenge {
IsActionReward: boolean
}
export interface MissionEndDrop {
export type MissionEndDrop = {
Unlockable: Unlockable
SourceChallenge?: MissionEndSourceChallenge
}
export interface MissionEndAchievedMastery {
export type MissionEndAchievedMastery = {
score: number
RatioParts: number
RatioTotal: number
@ -90,47 +109,38 @@ export interface MissionEndAchievedMastery {
BaseScore: number
}
export interface MissionEndEvergreen {
export type MissionEndEvergreen = {
Payout: number
EndStateEventName?: string
EndStateEventName?: string | null
PayoutsCompleted: MissionEndEvergreenPayout[]
PayoutsFailed: MissionEndEvergreenPayout[]
}
export interface MissionEndEvergreenPayout {
export type MissionEndEvergreenPayout = {
Name: string
Payout: number
IsPrestige: boolean
}
export interface MissionEndResult {
export type ContractScore = {
Total: number
AchievedMasteries: MissionEndAchievedMastery[]
AwardedBonuses: ScoringBonus[]
TotalNoMultipliers: number
TimeUsedSecs: Seconds
StarCount: number
FailedBonuses: ScoringBonus[]
SilentAssassin: boolean
}
export type MissionEndResult = {
MissionReward: {
LocationProgression: {
LevelInfo: number[]
XP: number
Level: number
Completion: number
XPGain: number
HideProgression: boolean
}
ProfileProgression: {
LevelInfo: number[]
LevelInfoOffset: number
XP: number
Level: number
XPGain: number
}
LocationProgression: ScoreProgressionStats
ProfileProgression: ScoreProfileProgressionStats
Challenges: MissionEndChallenge[]
Drops: MissionEndDrop[]
OpportunityRewards: unknown[] // ?
UnlockableProgression?: {
LevelInfo: number[]
XP: number
Level: number
XPGain: number
Id: string
Name: string
}
UnlockableProgression?: ScoreProgressionStats
CompletionData: CompletionData
ChallengeCompletion: ChallengeCompletion
ContractChallengeCompletion: ChallengeCompletion
@ -149,28 +159,8 @@ export interface MissionEndResult {
ScoreDetails: {
Headlines: ScoringHeadline[]
}
ContractScore?: {
Total: number
AchievedMasteries: MissionEndAchievedMastery[]
AwardedBonuses: ScoringBonus[]
TotalNoMultipliers: number
TimeUsedSecs: Seconds
StarCount: number
FailedBonuses: ScoringBonus[]
SilentAssassin: boolean
}
SniperChallengeScore?: {
FinalScore: number
BaseScore: number
TotalChallengeMultiplier: number
BulletsMissed: number
BulletsMissedPenalty: number
TimeTaken: number
TimeBonus: number
SilentAssassin: boolean
SilentAssassinBonus: number
SilentAssassinMultiplier: number
}
ContractScore?: ContractScore
SniperChallengeScore?: CalculateSniperScoreResult
SilentAssassin: boolean
NewRank: number
RankCount: number

View File

@ -19,7 +19,7 @@
import type * as core from "express-serve-static-core"
import type { IContractCreationPayload } from "../statemachines/contractCreation"
import type { Request } from "express"
import { Request } from "express"
import {
ChallengeContext,
ProfileChallengeData,
@ -29,6 +29,7 @@ import { SessionGhostModeDetails } from "../multiplayer/multiplayerService"
import { IContextListener } from "../statemachines/contextListeners"
import { ManifestScoringModule, ScoringModule } from "./scoring"
import { Timer } from "@peacockproject/statemachine-parser"
import { InventoryItem } from "../inventory"
/**
* A duration or relative point in time expressed in seconds.
@ -274,7 +275,7 @@ export interface ContractSession {
*/
evergreen?: {
payout: number
scoringScreenEndState: string
scoringScreenEndState: string | null
failed: boolean
}
/**
@ -367,7 +368,7 @@ export interface S2CEventWithTimestamp<EventValue = unknown> {
* A server to client push message. The message component is encoded JSON.
*/
export interface PushMessage {
time: number | string
time: number | string | bigint
message: string
}
@ -405,33 +406,30 @@ export interface MissionStory {
}
export interface PlayerProfileView {
template: unknown
data: {
SubLocationData: {
ParentLocation: Unlockable
Location: Unlockable
CompletionData: CompletionData
ChallengeCategoryCompletion: ChallengeCategoryCompletion[]
ChallengeCompletion: ChallengeCompletion
OpportunityStatistics: OpportunityStatistics
LocationCompletionPercent: number
}[]
PlayerProfileXp: {
Total: number
Level: number
Seasons: {
Number: number
Locations: {
LocationId: string
Xp: number
ActionXp: number
LocationProgression?: {
Level: number
MaxLevel: number
}
}[]
SubLocationData: {
ParentLocation: Unlockable
Location: Unlockable
CompletionData: CompletionData
ChallengeCategoryCompletion: ChallengeCategoryCompletion[]
ChallengeCompletion: ChallengeCompletion
OpportunityStatistics: OpportunityStatistics
LocationCompletionPercent: number
}[]
PlayerProfileXp: {
Total: number
Level: number
Seasons: {
Number: number
Locations: {
LocationId: string
Xp: number
ActionXp: number
LocationProgression?: {
Level: number
MaxLevel: number
}
}[]
}
}[]
}
}
@ -878,7 +876,7 @@ export type ContractGroupDefinition = {
Order: string[]
}
export interface EscalationInfo {
export type EscalationInfo = {
Type?: MissionType
InGroup?: string
NextContractId?: string
@ -893,77 +891,76 @@ export interface EscalationInfo {
export interface MissionManifestMetadata {
Id: string
Location: string
IsPublished?: boolean
CreationTimestamp?: string
CreatorUserId?: string
IsPublished?: boolean | null
CreationTimestamp?: string | null
CreatorUserId?: string | null
Title: string
Description?: string
Description?: string | null
BriefingVideo?:
| string
| {
Mode: string
VideoId: string
}[]
DebriefingVideo?: string
DebriefingVideo?: string | null
TileImage?:
| string
| {
Mode: string
Image: string
}[]
CodeName_Hint?: string
CodeName_Hint?: string | null
ScenePath: string
Type: MissionType
Release?: string | object
RequiredUnlockable?: string
Drops?: string[]
Opportunities?: string[]
OpportunityData?: MissionStory[]
Entitlements: string[]
LastUpdate?: string
PublicId?: string
GroupObjectiveDisplayOrder?: GroupObjectiveDisplayOrderItem[]
GameVersion?: string
ServerVersion?: string
AllowNonTargetKills?: boolean
Difficulty?: "pro1" | string
CharacterSetup?: {
Mode: "singleplayer" | "multiplayer" | string
Characters: {
Name: string
Id: string
MandatoryLoadout?: string[]
}[]
}[]
CharacterLoadoutData?: {
Id: string
Loadout: unknown
CompletionData: CompletionData
}[]
SpawnSelectionType?: "random" | string
Gamemodes?: ("versus" | string)[]
Enginemodes?: ("singleplayer" | "multiplayer" | string)[]
Release?: string | object | null
RequiredUnlockable?: string | null
Drops?: string[] | null
Opportunities?: string[] | null
OpportunityData?: MissionStory[] | null
Entitlements: string[] | null
LastUpdate?: string | null
PublicId?: string | null
GroupObjectiveDisplayOrder?: GroupObjectiveDisplayOrderItem[] | null
GameVersion?: string | null
ServerVersion?: string | null
AllowNonTargetKills?: boolean | null
Difficulty?: "pro1" | string | null
CharacterSetup?:
| {
Mode: "singleplayer" | "multiplayer" | string
Characters: {
Name: string
Id: string
MandatoryLoadout?: string[]
}[]
}[]
| null
CharacterLoadoutData?:
| {
Id: string
Loadout: unknown
CompletionData: CompletionData
}[]
| null
SpawnSelectionType?: "random" | string | null
Gamemodes?: ("versus" | string)[] | null
Enginemodes?: ("singleplayer" | "multiplayer" | string)[] | null
EndConditions?: {
PointLimit?: number
}
Subtype?: string
GroupTitle?: string
TargetExpiration?: number
TargetExpirationReduced?: number
TargetLifeTime?: number
NonTargetKillPenaltyEnabled?: boolean
NoticedTargetStreakPenaltyMax?: number
IsFeatured?: boolean
} | null
Subtype?: string | null
GroupTitle?: string | null
TargetExpiration?: number | null
TargetExpirationReduced?: number | null
TargetLifeTime?: number | null
NonTargetKillPenaltyEnabled?: boolean | null
NoticedTargetStreakPenaltyMax?: number | null
IsFeatured?: boolean | null
// Begin escalation-exclusive properties
InGroup?: string
NextContractId?: string
GroupDefinition?: ContractGroupDefinition
GroupData?: {
Level: number
TotalLevels: number
Completed: boolean
FirstContractId: string
}
InGroup?: string | null
NextContractId?: string | null
GroupDefinition?: ContractGroupDefinition | null
GroupData?: EscalationInfo["GroupData"] | null
// End escalation-exclusive properties
/**
* Useless property.
@ -971,19 +968,19 @@ export interface MissionManifestMetadata {
* @deprecated
*/
readonly UserData?: unknown | null
IsVersus?: boolean
IsEvergreenSafehouse?: boolean
UseContractProgressionData?: boolean
CpdId?: string
IsVersus?: boolean | null
IsEvergreenSafehouse?: boolean | null
UseContractProgressionData?: boolean | null
CpdId?: string | null
/**
* Custom property used for Elusives (like official's year)
* and Escalations (if it's 0, it is a Peacock escalation,
* and OriginalSeason will exist for filtering).
*/
Season?: number
OriginalSeason?: number
Season?: number | null
OriginalSeason?: number | null
// Used for sniper scoring
Modules?: ManifestScoringModule[]
Modules?: ManifestScoringModule[] | null
}
export interface GroupObjectiveDisplayOrderItem {
@ -1039,7 +1036,7 @@ export interface MissionManifest {
EnableExits?: {
$eq?: (string | boolean)[]
}
DevOnlyBricks?: string[]
DevOnlyBricks?: string[] | null
}
Metadata: MissionManifestMetadata
readonly UserData?: Record<string, never> | never[]
@ -1216,7 +1213,7 @@ export interface CompiledChallengeTreeData {
CategoryName: string
ChallengeProgress?: ChallengeTreeWaterfallState
Completed: boolean
CompletionData: CompletionData
CompletionData?: CompletionData
Description: string
// A string array of at most one element ("easy", "normal", or "hard").
// If empty, then the challenge should appear in sessions on any difficulty.
@ -1276,6 +1273,7 @@ export interface ChallengeProgressionData {
ProfileId: string
Completed: boolean
Ticked: boolean
ETag?: string
State: Record<string, unknown>
CompletedAt: Date | string | null
MustBeSaved: boolean
@ -1286,14 +1284,6 @@ export interface CompiledChallengeRuntimeData {
Progression: ChallengeProgressionData
}
export interface CompiledChallengeRewardData {
ChallengeId: string
ChallengeName: string
ChallengeDescription: string
ChallengeImageUrl: string
XPGain: number
}
export type LoadoutSavingMechanism = "PROFILES" | "LEGACY"
export type ImageLoadingStrategy = "SAVEASREQUESTED" | "ONLINE" | "OFFLINE"
@ -1322,7 +1312,7 @@ export interface IHit {
/**
* A video object.
*
* @see ICampaignVideo
* @see CampaignVideo
* @see StoryData
*/
export interface IVideo {
@ -1346,7 +1336,7 @@ export interface IVideo {
*
* @see IHit
*/
export type ICampaignMission = {
export type CampaignMission = {
Type: "Mission"
Data: IHit
}
@ -1356,7 +1346,7 @@ export type ICampaignMission = {
*
* @see IVideo
*/
export type ICampaignVideo = {
export type CampaignVideo = {
Type: "Video"
Data: IVideo
}
@ -1373,7 +1363,7 @@ export interface RegistryChallenge extends SavedChallenge {
/**
* An element for the game's story data.
*/
export type StoryData = ICampaignMission | ICampaignVideo
export type StoryData = CampaignMission | CampaignVideo
/**
* A campaign object.
@ -1415,7 +1405,7 @@ export interface Loadout {
*
* @see LoadoutFile
*/
export interface LoadoutsGameVersion {
export type LoadoutsGameVersion = {
selected: string | null
loadouts: Loadout[]
}
@ -1423,19 +1413,20 @@ export interface LoadoutsGameVersion {
/**
* The top-level format for the loadout profiles storage file.
*/
export interface LoadoutFile {
h1: LoadoutsGameVersion
h2: LoadoutsGameVersion
h3: LoadoutsGameVersion
}
export type LoadoutFile = Record<
// game version but not scpc
Exclude<GameVersion, "scpc">,
LoadoutsGameVersion
>
/**
* A function that generates a campaign mission object for use in the campaigns menu.
* Will throw if contract is not found.
*/
export type GenSingleMissionFunc = (
contractId: string,
gameVersion: GameVersion,
) => ICampaignMission
) => CampaignMission
/**
* A function that generates a campaign video object for use in the campaigns menu.
@ -1443,7 +1434,7 @@ export type GenSingleMissionFunc = (
export type GenSingleVideoFunc = (
videoId: string,
gameVersion: GameVersion,
) => ICampaignVideo
) => CampaignVideo
/**
* A "hits category" is used to display lists of contracts in-game.
@ -1486,16 +1477,25 @@ export interface PlayNextGetCampaignsHookReturn {
export type SafehouseCategory = {
Category: string
SubCategories: SafehouseCategory[]
SubCategories: SafehouseCategory[] | null
IsLeaf: boolean
Data: null
}
export type SniperLoadout = {
ID: string
InstanceID: string
Unlockable: Unlockable[]
MainUnlockable: Unlockable
Data: null | {
Type: string
SubType: string | undefined
Items: {
Item: InventoryItem
ItemDetails: {
Capabilities: []
StatList: {
Name: string
Ratio: unknown
PropertyTexts: []
}[]
}
}[]
Page: number
HasMore: boolean
}
}
/**

View File

@ -28,7 +28,7 @@ import type {
Unlockable,
UserProfile,
} from "./types/types"
import axios, { AxiosError } from "axios"
import { AxiosError } from "axios"
import { log, LogLevel } from "./loggingInterop"
import { writeFileSync } from "fs"
import { getFlag } from "./flags"
@ -54,7 +54,7 @@ export const uuidRegex =
export const contractTypes = ["featured", "usercreated"]
export const versions: GameVersion[] = ["h1", "h2", "h3"]
export const versions: Exclude<GameVersion, "scpc">[] = ["h1", "h2", "h3"]
export const contractCreationTutorialId = "d7e2607c-6916-48e2-9588-976c7d8998bb"
@ -71,10 +71,10 @@ export async function checkForUpdates(): Promise<void> {
}
try {
const res = await axios(
const res = await fetch(
"https://backend.rdil.rocks/peacock/latest-version",
)
const current = res.data
const current = parseInt(await res.text(), 10)
if (PEACOCKVER < 0 && current < -PEACOCKVER) {
log(
@ -94,12 +94,15 @@ export async function checkForUpdates(): Promise<void> {
}
}
export function getRemoteService(gameVersion: GameVersion): string {
return gameVersion === "h3"
? "hm3-service"
: gameVersion === "h2"
? "pc2-service"
: "pc-service"
export function getRemoteService(gameVersion: GameVersion): string | undefined {
switch (gameVersion) {
case "h3":
return "hm3-service"
case "h2":
return "pc2-service"
default:
return "pc-service"
}
}
/**
@ -306,6 +309,7 @@ function updateUserProfile(
if (gameVersion === "h1") {
// No sniper locations, but we add normal and pro1
// @ts-expect-error I know what I'm doing.
obj[newKey] = {
// Data from previous profiles only contains normal and is the default.
normal: {
@ -321,8 +325,10 @@ function updateUserProfile(
}
} else {
// We need to update sniper locations.
// @ts-expect-error I know what I'm doing.
obj[newKey] = sniperLocs[newKey]
? sniperLocs[newKey].reduce((obj, uId) => {
? // @ts-expect-error I know what I'm doing.
sniperLocs[newKey].reduce((obj, uId) => {
obj[uId] = {
Xp: 0,
Level: 1,
@ -344,7 +350,7 @@ function updateUserProfile(
{},
)
// ts-expect-error Legacy property.
// @ts-expect-error Legacy property.
delete profile.Extensions.progression["Unlockables"]
profile.Version = 1
@ -426,12 +432,7 @@ export function castUserProfile(
// Fix Extensions.gamepersistentdata.HitsFilterType.
// None of the old profiles should have "MyPlaylist".
if (
!Object.hasOwn(
j.Extensions.gamepersistentdata.HitsFilterType,
"MyPlaylist",
)
) {
if (j.Extensions.gamepersistentdata.HitsFilterType["MyPlaylist"]) {
j.Extensions.gamepersistentdata.HitsFilterType = {
MyHistory: "all",
MyContracts: "all",
@ -520,15 +521,17 @@ export const defaultSuits = {
* @returns The default suits that are attainable via challenges or mastery.
*/
export function attainableDefaults(gameVersion: GameVersion): string[] {
return gameVersion === "h1"
? []
: gameVersion === "h2"
? ["TOKEN_OUTFIT_WET_SUIT"]
: [
"TOKEN_OUTFIT_GREENLAND_HERO_TRAININGSUIT",
"TOKEN_OUTFIT_WET_SUIT",
"TOKEN_OUTFIT_HERO_DUGONG_SUIT",
]
if (gameVersion === "h1") {
return []
} else if (gameVersion === "h2") {
return ["TOKEN_OUTFIT_WET_SUIT"]
} else {
return [
"TOKEN_OUTFIT_GREENLAND_HERO_TRAININGSUIT",
"TOKEN_OUTFIT_WET_SUIT",
"TOKEN_OUTFIT_HERO_DUGONG_SUIT",
]
}
}
/**
@ -538,10 +541,11 @@ export function attainableDefaults(gameVersion: GameVersion): string[] {
* @param subLocation The sub-location.
* @returns The default suit for the given sub-location and parent location.
*/
export function getDefaultSuitFor(subLocation: Unlockable): string | undefined {
export function getDefaultSuitFor(subLocation: Unlockable): string {
type Cast = keyof typeof defaultSuits
return (
defaultSuits[subLocation.Id] ||
defaultSuits[subLocation.Properties.ParentLocation] ||
defaultSuits[subLocation.Id as Cast] ||
defaultSuits[subLocation.Properties.ParentLocation as Cast] ||
"TOKEN_OUTFIT_HITMANSUIT"
)
}

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Request, Response, Router } from "express"
import { NextFunction, Request, Response, Router } from "express"
import { getConfig } from "./configSwizzleManager"
import { readFileSync } from "atomically"
import { GameVersion, UserProfile } from "./types/types"
@ -41,6 +41,40 @@ if (PEACOCK_DEV) {
})
}
type CommonRequest<ExtraQuery = Record<never, never>> = Request<
unknown,
unknown,
unknown,
{
user: string
gv: Exclude<GameVersion, "scpc">
} & ExtraQuery
>
function commonValidationMiddleware(
req: CommonRequest,
res: Response,
next: NextFunction,
): void {
if (!req.query.gv || !versions.includes(req.query.gv ?? null)) {
res.json({
success: false,
error: "invalid game version",
})
return
}
if (!req.query.user || !uuidRegex.test(req.query.user)) {
res.json({
success: false,
error: "The request must contain the uuid of a user.",
})
return
}
next()
}
function formErrorMessage(res: Response, message: string): void {
res.json({
success: false,
@ -48,83 +82,49 @@ function formErrorMessage(res: Response, message: string): void {
})
}
webFeaturesRouter.get("/codenames", (req, res) => {
webFeaturesRouter.get("/codenames", (_, res) => {
res.json(getConfig("EscalationCodenames", false))
})
webFeaturesRouter.get(
"/local-users",
(req: Request<unknown, unknown, unknown, { gv: GameVersion }>, res) => {
if (!req.query.gv || !versions.includes(req.query.gv ?? null)) {
res.json([])
return
}
let dir
if (req.query.gv === "h3") {
dir = join("userdata", "users")
} else {
dir = join("userdata", req.query.gv, "users")
}
const files: string[] = readdirSync(dir).filter(
(name) => name !== "lop.json",
)
const result = []
for (const file of files) {
const read = JSON.parse(
readFileSync(join(dir, file)).toString(),
) as UserProfile
result.push({
id: read.Id,
name: read.Gamertag,
platform: read.EpicId ? "Epic" : "Steam",
})
}
res.json(result)
},
)
function validateUserAndGv(
req: Request<unknown, unknown, unknown, { gv: GameVersion; user: string }>,
res: Response,
): boolean {
webFeaturesRouter.get("/local-users", (req: CommonRequest, res) => {
if (!req.query.gv || !versions.includes(req.query.gv ?? null)) {
formErrorMessage(
res,
'The request must contain a valid game version among "h1", "h2", and "h3".',
)
return false
res.json([])
return
}
if (!req.query.user || !uuidRegex.test(req.query.user)) {
formErrorMessage(res, "The request must contain the uuid of a user.")
return false
let dir
if (req.query.gv === "h3") {
dir = join("userdata", "users")
} else {
dir = join("userdata", req.query.gv, "users")
}
return true
}
const files: string[] = readdirSync(dir).filter(
(name) => name !== "lop.json",
)
const result = []
for (const file of files) {
const read = JSON.parse(
readFileSync(join(dir, file)).toString(),
) as UserProfile
result.push({
id: read.Id,
name: read.Gamertag,
platform: read.EpicId ? "Epic" : "Steam",
})
}
res.json(result)
})
webFeaturesRouter.get(
"/modify",
async (
req: Request<
unknown,
unknown,
unknown,
{ gv: GameVersion; user: string; level: string; id: string }
>,
res,
) => {
if (!validateUserAndGv(req, res)) {
return
}
commonValidationMiddleware,
async (req: CommonRequest<{ level: string; id: string }>, res) => {
if (!req.query.level) {
formErrorMessage(
res,
@ -158,7 +158,7 @@ webFeaturesRouter.get(
const mapping = controller.escalationMappings.get(req.query.id)
if (mapping === undefined) {
if (!mapping) {
formErrorMessage(res, "Unknown escalation.")
return
}
@ -198,19 +198,8 @@ webFeaturesRouter.get(
webFeaturesRouter.get(
"/user-progress",
async (
req: Request<
unknown,
unknown,
unknown,
{ gv: GameVersion; user: string }
>,
res,
) => {
if (!validateUserAndGv(req, res)) {
return
}
commonValidationMiddleware,
async (req: CommonRequest, res) => {
try {
await loadUserData(req.query.user, req.query.gv)
} catch (e) {

View File

@ -19,7 +19,8 @@
"webui": "yarn workspace @peacockproject/web-ui",
"typedefs": "yarn workspace @peacockproject/core",
"run-dev": "node packaging/devLoader.mjs",
"extract-challenge-data": "node packaging/extractChallengeData.mjs"
"extract-challenge-data": "node packaging/extractChallengeData.mjs",
"find-circular": "yarn dlx dpdm components/index.ts --exclude \"(components/types)|(node_modules)\" -T"
},
"prettier": {
"semi": false,
@ -27,7 +28,7 @@
"trailingComma": "all"
},
"resolutions": {
"body-parser": "npm:@peacockproject/body-parser@npm:2.0.0-peacock.6",
"body-parser": "npm:@peacockproject/body-parser@npm:3.0.0-peacock.1",
"debug": "^4.3.4",
"http-errors": "patch:http-errors@npm:2.0.0#.yarn/patches/http-errors-npm-2.0.0-3f1c503428.patch",
"iconv-lite": "patch:iconv-lite@npm:0.6.3#.yarn/patches/iconv-lite-npm-0.6.3-24b8aae27e.patch",
@ -40,18 +41,18 @@
"atomically": "^2.0.2",
"axios": "^1.6.0",
"body-parser": "*",
"clipanion": "^3.2.1",
"clipanion": "^4.0.0-rc.3",
"commander": "^11.1.0",
"deepmerge-ts": "^5.1.0",
"esbuild-wasm": "^0.19.5",
"esbuild-wasm": "^0.19.12",
"express": "patch:express@npm%3A4.18.2#~/.yarn/patches/express-npm-4.18.2-bb15ff679a.patch",
"jest-diff": "^29.7.0",
"js-ini": "^1.6.0",
"json5": "^2.2.3",
"jsonwebtoken": "^9.0.2",
"md5-file": "^5.0.0",
"msgpackr": "^1.9.9",
"nanoid": "^5.0.3",
"msgpackr": "^1.10.1",
"nanoid": "^5.0.4",
"parseurl": "^1.3.3",
"picocolors": "patch:picocolors@npm%3A1.0.0#~/.yarn/patches/picocolors-npm-1.0.0-d81e0b1927.patch",
"progress": "^2.0.3",
@ -71,12 +72,12 @@
"@types/progress": "^2.0.6",
"@types/prompts": "^2.4.7",
"@types/send": "^0.17.3",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"esbuild": "^0.19.5",
"@typescript-eslint/eslint-plugin": "^6.19.1",
"@typescript-eslint/parser": "^6.19.1",
"esbuild": "^0.19.12",
"esbuild-register": "^3.5.0",
"eslint": "^8.53.0",
"eslint-config-prettier": "^9.0.0",
"eslint": "^8.56.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react-hooks": "^4.6.0",
"fast-glob": "^3.3.2",
@ -84,8 +85,8 @@
"ms": "^2.1.3",
"prettier": "^2.8.8",
"rimraf": "^5.0.5",
"terser": "^5.21.0",
"typescript": "5.2.2",
"terser": "^5.27.0",
"typescript": "5.3.3",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1"
},

View File

@ -74,6 +74,7 @@ await e.build({
"process.env.ZEIT_BITBUCKET_COMMIT_SHA": "undefined",
"process.env.VERCEL_GIT_COMMIT_SHA": "undefined",
"process.env.ZEIT_GITLAB_COMMIT_SHA": "undefined",
"process.env.MSGPACKR_NATIVE_ACCELERATION_DISABLED": "true",
},
sourcemap: "external",
plugins: [

View File

@ -23,12 +23,12 @@
"dependencies": {
"@peacockproject/statemachine-parser": "^5.9.3",
"@types/express": "^4.17.20",
"@types/node": "^20.8.10",
"@types/node": "*",
"atomically": "^2.0.2",
"axios": "^1.6.0",
"axios": "^1.6.7",
"js-ini": "^1.6.0"
},
"optionalDependencies": {
"msgpackr": "^1.9.9"
"msgpackr": "^1.10.1"
}
}

View File

@ -1,245 +1,242 @@
{
"template": null,
"data": {
"SubLocationData": [],
"PlayerProfileXp": {
"Total": 0,
"Level": 1,
"Seasons": [
{
"Number": 1,
"Locations": [
{
"LocationId": "LOCATION_PARENT_ICA_FACILITY",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_PARIS",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_COASTALTOWN",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_MARRAKECH",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_BANGKOK",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_COLORADO",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_HOKKAIDO",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
"SubLocationData": [],
"PlayerProfileXp": {
"Total": 0,
"Level": 1,
"Seasons": [
{
"Number": 1,
"Locations": [
{
"LocationId": "LOCATION_PARENT_ICA_FACILITY",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_PARIS",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
]
},
{
"Number": 2,
"Locations": [
{
"LocationId": "LOCATION_PARENT_NEWZEALAND",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 5
}
},
{
"LocationId": "LOCATION_PARENT_MIAMI",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_COLOMBIA",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_MUMBAI",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_NORTHAMERICA",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_NORTHSEA",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_GREEDY",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_OPULENT",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_AUSTRIA",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_SALTY",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_CAGED",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_COASTALTOWN",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
]
},
{
"Number": 3,
"Locations": [
{
"LocationId": "LOCATION_PARENT_GOLDEN",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_ANCESTRAL",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_EDGY",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_WET",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_ELEGANT",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_TRAPPED",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 5
}
},
{
"LocationId": "LOCATION_PARENT_ROCKY",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_SNUG",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 100
}
},
{
"LocationId": "LOCATION_PARENT_MARRAKECH",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
]
}
]
}
},
{
"LocationId": "LOCATION_PARENT_BANGKOK",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_COLORADO",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_HOKKAIDO",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
}
]
},
{
"Number": 2,
"Locations": [
{
"LocationId": "LOCATION_PARENT_NEWZEALAND",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 5
}
},
{
"LocationId": "LOCATION_PARENT_MIAMI",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_COLOMBIA",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_MUMBAI",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_NORTHAMERICA",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_NORTHSEA",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_GREEDY",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_OPULENT",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_AUSTRIA",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_SALTY",
"Xp": 0,
"ActionXp": 0
},
{
"LocationId": "LOCATION_PARENT_CAGED",
"Xp": 0,
"ActionXp": 0
}
]
},
{
"Number": 3,
"Locations": [
{
"LocationId": "LOCATION_PARENT_GOLDEN",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_ANCESTRAL",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_EDGY",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_WET",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_ELEGANT",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_TRAPPED",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 5
}
},
{
"LocationId": "LOCATION_PARENT_ROCKY",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 20
}
},
{
"LocationId": "LOCATION_PARENT_SNUG",
"Xp": 0,
"ActionXp": 0,
"LocationProgression": {
"Level": 1,
"MaxLevel": 100
}
}
]
}
]
}
}

View File

@ -26,8 +26,11 @@ export function asMock<T>(value: T): Mock {
return value as Mock
}
export function mockRequestWithJwt(): RequestWithJwt<core.Query, any> {
const mockedRequest = <RequestWithJwt<core.Query, any>>{
export function mockRequestWithJwt<
QS = core.Query,
Body = any,
>(): RequestWithJwt<QS, Body> {
const mockedRequest = <RequestWithJwt<QS, Body>>{
headers: {},
header: (name: string) =>
mockedRequest.headers[name.toLowerCase()] as string,
@ -36,10 +39,10 @@ export function mockRequestWithJwt(): RequestWithJwt<core.Query, any> {
return mockedRequest
}
export function mockRequestWithValidJwt(
export function mockRequestWithValidJwt<QS = core.Query, Body = any>(
pId: string,
): RequestWithJwt<core.Query, any> {
const mockedRequest = mockRequestWithJwt()
): RequestWithJwt<QS, Body> {
const mockedRequest = mockRequestWithJwt<QS, Body>()
const jwtToken = sign(
{
@ -64,15 +67,20 @@ export function mockResponse(): core.Response {
return response
}
// @ts-expect-error It works.
response.status = vi.fn().mockImplementation(mockImplementation)
// @ts-expect-error It works.
response.json = vi.fn()
// @ts-expect-error It works.
response.end = vi.fn()
// @ts-expect-error It works.
return <core.Response>response
}
export function getResolvingPromise<T>(value?: T): Promise<T> {
return new Promise((resolve) => {
// @ts-expect-error It works.
resolve(value)
})
}

View File

@ -18,12 +18,15 @@
import * as configSwizzleManager from "../../components/configSwizzleManager"
import { readFileSync } from "fs"
import { vi } from "vitest"
const originalFilePaths: Record<string, string> = {}
Object.keys(configSwizzleManager.configs).forEach((config: string) => {
// @ts-expect-error It works.
originalFilePaths[config] = <string>configSwizzleManager.configs[config]
// @ts-expect-error It works.
configSwizzleManager.configs[config] = undefined
})
@ -34,20 +37,24 @@ export function loadConfig(config: string) {
const contents = readFileSync(originalFilePaths[config], "utf-8")
// @ts-expect-error It works.
configSwizzleManager.configs[config] = JSON.parse(contents)
}
export function setConfig(config: string, data: unknown) {
// @ts-expect-error It works.
configSwizzleManager.configs[config] = data
}
const getConfigOriginal = configSwizzleManager.getConfig
vi.spyOn(configSwizzleManager, "getConfig").mockImplementation(
(config: string, clone: boolean) => {
// @ts-expect-error It works.
if (!configSwizzleManager.configs[config]) {
throw `Config '${config}' has not been loaded!`
}
// @ts-expect-error It works.
return getConfigOriginal(config, clone)
},
)

View File

@ -3,12 +3,13 @@
"type": "module",
"private": true,
"dependencies": {
"@vitest/ui": "^0.34.6",
"@vitest/ui": "^1.2.2",
"vite": "^5.0.12",
"vitest": "^0.34.6"
"vitest": "^1.2.2"
},
"scripts": {
"test:main": "vitest --run --config vitest.config.ts",
"test:ui": "vitest --config vitest.config.ts --ui"
"test:ui": "vitest --config vitest.config.ts --ui",
"typecheck-ws": "tsc --noEmit"
}
}

View File

@ -18,7 +18,12 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { UserProfile } from "../../components/types/types"
import { handleOauthToken, JWT_SECRET } from "../../components/oauthToken"
import {
error406,
handleOAuthToken,
JWT_SECRET,
OAuthTokenResponse,
} from "../../components/oauthToken"
import { sign, verify } from "jsonwebtoken"
import * as databaseHandler from "../../components/databaseHandler"
import * as platformEntitlements from "../../components/platformEntitlements"
@ -26,11 +31,9 @@ import axios from "axios"
import { describe, expect, beforeEach, vi, it } from "vitest"
import {
getMockCallArgument,
getResolvingPromise,
mockRequestWithJwt,
mockRequestWithValidJwt,
mockResponse,
} from "../helpers/testHelpers"
describe("oauthToken", () => {
@ -41,6 +44,7 @@ describe("oauthToken", () => {
.mockResolvedValue("")
const loadUserData = vi
.spyOn(databaseHandler, "loadUserData")
// @ts-expect-error This is okay.
.mockResolvedValue(undefined)
const getUserData = vi
.spyOn(databaseHandler, "getUserData")
@ -65,10 +69,10 @@ describe("oauthToken", () => {
})
}
return undefined
return getResolvingPromise({})
})
const request = mockRequestWithJwt()
const request = mockRequestWithJwt<never, any>()
request.body = {
grant_type: "external_steam",
steam_userid: "000000000047",
@ -76,9 +80,7 @@ describe("oauthToken", () => {
pId: pId,
}
const response = mockResponse()
await handleOauthToken(request, response)
const res = await handleOAuthToken(request)
expect(getExternalUserData).toHaveBeenCalledWith(
"000000000047",
@ -88,12 +90,15 @@ describe("oauthToken", () => {
expect(loadUserData).toHaveBeenCalledWith(pId, "h3")
expect(getUserData).toHaveBeenCalledWith(pId, "h3")
const jsonResponse = getMockCallArgument<any>(response.json, 0, 0)
const accessToken = verify(jsonResponse.access_token, JWT_SECRET, {
complete: true,
})
const accessToken = verify(
(res as OAuthTokenResponse).access_token,
JWT_SECRET,
{
complete: true,
},
)
expect(jsonResponse.token_type).toBe("bearer")
expect((res as OAuthTokenResponse).token_type).toBe("bearer")
expect((accessToken.payload as any).unique_name).toBe(pId)
})
@ -102,7 +107,7 @@ describe("oauthToken", () => {
["mock"],
)
const request = mockRequestWithJwt()
const request = mockRequestWithJwt<never, any>()
request.body = {
grant_type: "external_epic",
epic_userid: "0123456789abcdef0123456789abcdef",
@ -118,9 +123,7 @@ describe("oauthToken", () => {
pId: pId,
}
const response = mockResponse()
await handleOauthToken(request, response)
const res = await handleOAuthToken(request)
expect(getExternalUserData).toHaveBeenCalledWith(
"0123456789abcdef0123456789abcdef",
@ -130,85 +133,84 @@ describe("oauthToken", () => {
expect(loadUserData).toHaveBeenCalledWith(pId, "h3")
expect(getUserData).toHaveBeenCalledWith(pId, "h3")
const jsonResponse = getMockCallArgument<any>(response.json, 0, 0)
const accessToken = verify(jsonResponse.access_token, JWT_SECRET, {
complete: true,
})
const accessToken = verify(
(res as OAuthTokenResponse).access_token,
JWT_SECRET,
{
complete: true,
},
)
expect(jsonResponse.token_type).toBe("bearer")
expect((res as OAuthTokenResponse).token_type).toBe("bearer")
expect((accessToken.payload as any).unique_name).toBe(pId)
})
it("refresh_token - missing auth header", async () => {
const request = mockRequestWithJwt()
const request = mockRequestWithJwt<never, any>()
request.body = {
grant_type: "refresh_token",
}
const respose = mockResponse()
let error: Error = undefined
let error: Error | undefined = undefined
try {
await handleOauthToken(request, respose)
await handleOAuthToken(request)
} catch (e) {
error = e
error = e as Error
}
expect(error).toBeInstanceOf(TypeError)
})
it("refresh_token - invalid auth header", async () => {
const request = mockRequestWithJwt()
const request = mockRequestWithJwt<never, any>()
request.headers.authorization = "Bearer invalid"
request.body = {
grant_type: "refresh_token",
}
const respose = mockResponse()
let error: Error = undefined
let error: Error | undefined = undefined
try {
await handleOauthToken(request, respose)
await handleOAuthToken(request)
} catch (e) {
error = e
error = e as Error
}
expect(error).toBeInstanceOf(TypeError)
})
it("refresh_token - valid auth header", async () => {
const request = mockRequestWithValidJwt(pId)
const request = mockRequestWithValidJwt<never>(pId)
// NOTE: We don't care about the actual values
request.body = {
grant_type: "refresh_token",
}
const response = mockResponse()
const res = await handleOAuthToken(request)
await handleOauthToken(request, response)
const accessToken = verify(
(res as OAuthTokenResponse).access_token,
JWT_SECRET,
{
complete: true,
},
)
const jsonResponse = getMockCallArgument<any>(response.json, 0, 0)
const accessToken = verify(jsonResponse.access_token, JWT_SECRET, {
complete: true,
})
expect(jsonResponse.token_type).toBe("bearer")
expect((res as OAuthTokenResponse).token_type).toBe("bearer")
expect((accessToken.payload as any).unique_name).toBe(pId)
})
it("no grant_type", async () => {
const request = mockRequestWithJwt()
const request = mockRequestWithJwt<never, any>()
request.body = {}
request.query = {} as never
const respose = mockResponse()
const res = await handleOAuthToken(request)
await handleOauthToken(request, respose)
expect(respose.status).toHaveBeenCalledWith(406)
expect(res).toEqual(error406)
})
})

View File

@ -4,7 +4,8 @@
// Reset rootDir to default to make rootDirs take effect
"rootDir": null,
"rootDirs": ["../components", "."],
"types": ["vitest/globals"]
"noEmit": true,
"emitDeclarationOnly": false
},
"include": ["../components", "**/*.ts"],
"exclude": []

View File

@ -6,7 +6,7 @@
"lib": ["ESNext"],
"emitDeclarationOnly": true,
"checkJs": false,
"strict": false,
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
@ -21,7 +21,8 @@
"rootDir": "components",
"outDir": "./build",
"isolatedModules": true,
"stripInternal": true
"stripInternal": true,
"strictNullChecks": true
},
"include": ["components"],
"exclude": [

View File

@ -4,7 +4,7 @@
"private": true,
"dependencies": {
"axios": "^1.6.0",
"clsx": "^2.0.0",
"clsx": "^2.1.0",
"immer": "^10.0.3",
"infima": "0.2.0-alpha.38",
"json-keys-sort": "^2.1.0",
@ -20,10 +20,10 @@
"typecheck-ws": "tsc"
},
"devDependencies": {
"@types/react": "^18.2.36",
"@types/react-dom": "^18.2.14",
"@types/react": "^18.2.48",
"@types/react-dom": "^18.2.18",
"rollup-plugin-license": "^3.2.0",
"typescript": "5.2.2",
"typescript": "5.3.3",
"vite": "^5.0.12"
},
"peerDependencies": {

1717
yarn.lock

File diff suppressed because it is too large Load Diff