mirror of
https://github.com/thepeacockproject/Peacock
synced 2024-11-22 22:12:45 +01:00
Add support for XP progression (#86)
* Fixed issue where restarting Peacock would require to first go offline again * Added proper player progression * Fixed most issues with the mission end screen * Added final tweaks to scoring * Update global challenges * Added near-complete support for Freelancer Added support for unlocking shortcuts Cleaned up a bunch of magic values in relation to XP and levels --------- Co-authored-by: moonysolari <changyiding@126.com>
This commit is contained in:
parent
c7d676d0af
commit
ba9b799abe
@ -32,10 +32,11 @@ import type {
|
||||
} from "../types/types"
|
||||
import { getUserData, writeUserData } from "../databaseHandler"
|
||||
|
||||
import { Controller } from "../controller"
|
||||
import { controller, Controller } from "../controller"
|
||||
import {
|
||||
generateCompletionData,
|
||||
generateUserCentric,
|
||||
getSubLocationByName,
|
||||
getSubLocationFromContract,
|
||||
} from "../contracts/dataGen"
|
||||
import { log, LogLevel } from "../loggingInterop"
|
||||
@ -48,11 +49,21 @@ import {
|
||||
HandleEventOptions,
|
||||
} from "@peacockproject/statemachine-parser"
|
||||
import { SavedChallengeGroup } from "../types/challenges"
|
||||
import { fastClone } from "../utils"
|
||||
import {
|
||||
clampValue,
|
||||
DEFAULT_MASTERY_MAXLEVEL,
|
||||
evergreenLevelForXp,
|
||||
fastClone,
|
||||
getMaxProfileLevel,
|
||||
levelForXp,
|
||||
xpRequiredForEvergreenLevel,
|
||||
xpRequiredForLevel,
|
||||
} from "../utils"
|
||||
import {
|
||||
ChallengeFilterOptions,
|
||||
ChallengeFilterType,
|
||||
filterChallenge,
|
||||
inclusionDataCheck,
|
||||
mergeSavedChallengeGroups,
|
||||
} from "./challengeHelpers"
|
||||
import assert from "assert"
|
||||
@ -324,6 +335,11 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
let challenges: [string, RegistryChallenge[]][] = []
|
||||
|
||||
for (const groupId of this.groups.get(location).keys()) {
|
||||
// if this is the global group, skip it.
|
||||
if (groupId === "global") {
|
||||
continue
|
||||
}
|
||||
|
||||
const groupContents = this.getGroupContentByIdLoc(groupId, location)
|
||||
if (groupContents) {
|
||||
let groupChallenges: RegistryChallenge[] | string[] = [
|
||||
@ -429,12 +445,42 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
gameVersion,
|
||||
)
|
||||
|
||||
const contractJson = this.controller.resolveContract(contractId)
|
||||
|
||||
if (contractJson.Metadata.Type === "evergreen") {
|
||||
session.evergreen = {
|
||||
payout: 0,
|
||||
scoringScreenEndState: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: Add this to getChallengesForContract without breaking the rest of Peacock?
|
||||
challengeGroups["global"] = this.getGroupByIdLoc(
|
||||
"global",
|
||||
"GLOBAL",
|
||||
).Challenges.filter((val) =>
|
||||
inclusionDataCheck(val.InclusionData, contractJson),
|
||||
)
|
||||
|
||||
const profile = getUserData(session.userId, session.gameVersion)
|
||||
|
||||
for (const group of Object.keys(challengeGroups)) {
|
||||
for (const challenge of challengeGroups[group]) {
|
||||
const isDone = this.fastGetIsCompleted(profile, challenge.Id)
|
||||
|
||||
if (
|
||||
challenge.Definition.Scope === "profile" ||
|
||||
challenge.Definition.Scope === "hit"
|
||||
) {
|
||||
profile.Extensions.ChallengeProgression[challenge.Id] ??= {
|
||||
Ticked: false,
|
||||
Completed: false,
|
||||
State:
|
||||
(<ChallengeDefinitionLike>challenge?.Definition)
|
||||
?.Context || {},
|
||||
}
|
||||
}
|
||||
|
||||
// For challenges with scopes being "profile" or "hit",
|
||||
// update challenge progression with the user's progression data
|
||||
const ctx =
|
||||
@ -451,6 +497,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
context: ctx,
|
||||
state: isDone ? "Success" : "Start",
|
||||
timers: [],
|
||||
timesCompleted: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -488,6 +535,8 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
currentState: data.state,
|
||||
timers: data.timers,
|
||||
timestamp: event.Timestamp,
|
||||
//logger: (category, message) =>
|
||||
// log(LogLevel.DEBUG, `[${category}] ${message}`),
|
||||
}
|
||||
|
||||
const previousState = data.state
|
||||
@ -499,6 +548,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
event.Value,
|
||||
options,
|
||||
)
|
||||
|
||||
// For challenges with scopes being "profile" or "hit",
|
||||
// save challenge progression to the user's progression data
|
||||
if (
|
||||
@ -519,6 +569,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
|
||||
if (previousState !== "Success" && result.state === "Success") {
|
||||
this.onChallengeCompleted(
|
||||
session,
|
||||
session.userId,
|
||||
session.gameVersion,
|
||||
challenge,
|
||||
@ -991,6 +1042,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
* @param gameVersion The game version.
|
||||
*/
|
||||
public tryToCompleteChallenge(
|
||||
session: ContractSession,
|
||||
challengeId: string,
|
||||
userData: UserProfile,
|
||||
parentId: string,
|
||||
@ -1037,6 +1089,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
}
|
||||
|
||||
this.onChallengeCompleted(
|
||||
session,
|
||||
userData.Id,
|
||||
gameVersion,
|
||||
this.getChallengeById(challengeId),
|
||||
@ -1045,6 +1098,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
}
|
||||
|
||||
private onChallengeCompleted(
|
||||
session: ContractSession,
|
||||
userId: string,
|
||||
gameVersion: GameVersion,
|
||||
challenge: RegistryChallenge,
|
||||
@ -1061,15 +1115,35 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
|
||||
const userData = getUserData(userId, gameVersion)
|
||||
|
||||
userData.Extensions.ChallengeProgression ??= {}
|
||||
//ASSUMED: Challenges that are not global should always be completed
|
||||
if (!challenge.Tags.includes("global")) {
|
||||
userData.Extensions.ChallengeProgression ??= {}
|
||||
|
||||
userData.Extensions.ChallengeProgression[challenge.Id] ??= {
|
||||
State: {},
|
||||
Completed: false,
|
||||
Ticked: false,
|
||||
userData.Extensions.ChallengeProgression[challenge.Id] ??= {
|
||||
State: {},
|
||||
Completed: false,
|
||||
Ticked: false,
|
||||
}
|
||||
|
||||
userData.Extensions.ChallengeProgression[challenge.Id].Completed =
|
||||
true
|
||||
}
|
||||
|
||||
userData.Extensions.ChallengeProgression[challenge.Id].Completed = true
|
||||
//Always count the number of completions
|
||||
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].state = "Start"
|
||||
}
|
||||
|
||||
//NOTE: Official will always grant XP to both Location Mastery and the Player Profile
|
||||
const totalXp =
|
||||
(challenge.Xp || 0) + (challenge.Rewards?.MasteryXP || 0)
|
||||
|
||||
this.grantLocationMasteryXp(totalXp, session, userData)
|
||||
this.grantUserXp(totalXp, session, userData)
|
||||
|
||||
writeUserData(userId, gameVersion)
|
||||
|
||||
@ -1078,6 +1152,7 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
// Check if completing this challenge also completes any dependency trees depending on it
|
||||
for (const depTreeId of this._dependencyTree.keys()) {
|
||||
this.tryToCompleteChallenge(
|
||||
session,
|
||||
depTreeId,
|
||||
userData,
|
||||
challenge.Id,
|
||||
@ -1085,4 +1160,87 @@ export class ChallengeService extends ChallengeRegistry {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
grantLocationMasteryXp(
|
||||
masteryXp: number,
|
||||
contractSession: ContractSession,
|
||||
userProfile: UserProfile,
|
||||
): boolean {
|
||||
const contract = controller.resolveContract(contractSession.contractId)
|
||||
|
||||
if (!contract) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subLocation = getSubLocationByName(
|
||||
contract.Metadata.Location,
|
||||
contractSession.gameVersion,
|
||||
)
|
||||
|
||||
const parentLocationId = subLocation
|
||||
? subLocation.Properties?.ParentLocation
|
||||
: contract.Metadata.Location
|
||||
|
||||
if (!parentLocationId) {
|
||||
return false
|
||||
}
|
||||
|
||||
const masteryData =
|
||||
this.controller.masteryService.getMasteryPackage(parentLocationId)
|
||||
|
||||
if (!masteryData) {
|
||||
return false
|
||||
}
|
||||
|
||||
const parentLocationIdLowerCase = parentLocationId.toLocaleLowerCase()
|
||||
|
||||
userProfile.Extensions.progression.Locations[
|
||||
parentLocationIdLowerCase
|
||||
] ??= {
|
||||
Xp: 0,
|
||||
Level: 1,
|
||||
}
|
||||
|
||||
const locationData =
|
||||
userProfile.Extensions.progression.Locations[
|
||||
parentLocationIdLowerCase
|
||||
]
|
||||
|
||||
const maxLevel = masteryData.MaxLevel || DEFAULT_MASTERY_MAXLEVEL
|
||||
|
||||
locationData.Xp = clampValue(
|
||||
locationData.Xp + masteryXp,
|
||||
0,
|
||||
contract.Metadata.Type !== "evergreen"
|
||||
? xpRequiredForLevel(maxLevel)
|
||||
: xpRequiredForEvergreenLevel(maxLevel),
|
||||
)
|
||||
|
||||
locationData.Level = clampValue(
|
||||
contract.Metadata.Type !== "evergreen"
|
||||
? levelForXp(locationData.Xp)
|
||||
: evergreenLevelForXp(locationData.Xp),
|
||||
1,
|
||||
maxLevel,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
grantUserXp(
|
||||
xp: number,
|
||||
contractSession: ContractSession,
|
||||
userProfile: UserProfile,
|
||||
): boolean {
|
||||
const profileData = userProfile.Extensions.progression.PlayerProfileXP
|
||||
|
||||
profileData.Total += xp
|
||||
profileData.ProfileLevel = clampValue(
|
||||
levelForXp(profileData.Total),
|
||||
1,
|
||||
getMaxProfileLevel(contractSession.gameVersion),
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
@ -27,7 +27,12 @@ import {
|
||||
MasteryPackage,
|
||||
} from "../types/mastery"
|
||||
import { CompletionData, GameVersion, Unlockable } from "../types/types"
|
||||
import { xpRequiredForLevel } from "../utils"
|
||||
import {
|
||||
clampValue,
|
||||
DEFAULT_MASTERY_MAXLEVEL,
|
||||
xpRequiredForLevel,
|
||||
XP_PER_LEVEL,
|
||||
} from "../utils"
|
||||
|
||||
export class MasteryService {
|
||||
private masteryData: Map<string, MasteryPackage> = new Map()
|
||||
@ -81,13 +86,13 @@ export class MasteryService {
|
||||
gameVersion: GameVersion,
|
||||
userId: string,
|
||||
): CompletionData {
|
||||
if (!this.masteryData.has(locationParentId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
//Get the mastery data
|
||||
const masteryData: MasteryPackage =
|
||||
this.masteryData.get(locationParentId)
|
||||
this.getMasteryPackage(locationParentId)
|
||||
|
||||
if (!masteryData) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
//Get the user profile
|
||||
const userProfile = getUserData(userId, gameVersion)
|
||||
@ -107,11 +112,12 @@ export class MasteryService {
|
||||
lowerCaseLocationParentId
|
||||
]
|
||||
|
||||
const maxLevel = masteryData.MaxLevel || 20
|
||||
const maxLevel = masteryData.MaxLevel || DEFAULT_MASTERY_MAXLEVEL
|
||||
|
||||
const nextLevel: number = Math.max(
|
||||
0,
|
||||
Math.min(locationData.Level + 1, maxLevel),
|
||||
const nextLevel: number = clampValue(
|
||||
locationData.Level + 1,
|
||||
1,
|
||||
maxLevel,
|
||||
)
|
||||
const nextLevelXp: number = xpRequiredForLevel(nextLevel)
|
||||
|
||||
@ -119,7 +125,8 @@ export class MasteryService {
|
||||
Level: locationData.Level,
|
||||
MaxLevel: maxLevel,
|
||||
XP: locationData.Xp,
|
||||
Completion: locationData.Xp / nextLevelXp,
|
||||
Completion:
|
||||
(XP_PER_LEVEL - (nextLevelXp - locationData.Xp)) / XP_PER_LEVEL,
|
||||
XpLeft: nextLevelXp - locationData.Xp,
|
||||
Id: masteryData.Id,
|
||||
SubLocationId: subLocationId,
|
||||
@ -129,20 +136,24 @@ export class MasteryService {
|
||||
}
|
||||
}
|
||||
|
||||
getMasteryPackage(locationParentId: string): MasteryPackage {
|
||||
if (!this.masteryData.has(locationParentId)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.masteryData.get(locationParentId)
|
||||
}
|
||||
|
||||
private getMasteryData(
|
||||
locationParentId: string,
|
||||
gameVersion: GameVersion,
|
||||
userId: string,
|
||||
): MasteryData[] {
|
||||
if (!this.masteryData.has(locationParentId)) {
|
||||
return []
|
||||
}
|
||||
|
||||
//Get the mastery data
|
||||
const masteryData: MasteryPackage =
|
||||
this.masteryData.get(locationParentId)
|
||||
this.getMasteryPackage(locationParentId)
|
||||
|
||||
if (masteryData.Drops.length === 0) {
|
||||
if (!masteryData || masteryData.Drops.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
|
@ -29,7 +29,11 @@ import { controller, preserveContracts } from "../controller"
|
||||
import { createLocationsData } from "../menus/destinations"
|
||||
import { userAuths } from "../officialServerAuth"
|
||||
import { log, LogLevel } from "../loggingInterop"
|
||||
import { getRemoteService, contractCreationTutorialId } from "../utils"
|
||||
import {
|
||||
getRemoteService,
|
||||
contractCreationTutorialId,
|
||||
getMaxProfileLevel,
|
||||
} from "../utils"
|
||||
|
||||
export function contractsModeHome(req: RequestWithJwt, res: Response): void {
|
||||
const contractsHomeTemplate = getConfig("ContractsTemplate", false)
|
||||
@ -58,7 +62,7 @@ export function contractsModeHome(req: RequestWithJwt, res: Response): void {
|
||||
XP: userData.Extensions.progression.PlayerProfileXP.Total,
|
||||
Level: userData.Extensions.progression.PlayerProfileXP
|
||||
.ProfileLevel,
|
||||
MaxLevel: 7500,
|
||||
MaxLevel: getMaxProfileLevel(req.gameVersion),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
import type {
|
||||
Campaign,
|
||||
ClientToServerEvent,
|
||||
CompiledChallengeRuntimeData,
|
||||
ContractSession,
|
||||
GameVersion,
|
||||
GenSingleMissionFunc,
|
||||
@ -36,6 +37,7 @@ import type {
|
||||
MissionManifest,
|
||||
PeacockLocationsData,
|
||||
PlayNextGetCampaignsHookReturn,
|
||||
RegistryChallenge,
|
||||
RequestWithJwt,
|
||||
S2CEventWithTimestamp,
|
||||
SMFLastDeploy,
|
||||
@ -81,7 +83,7 @@ import { createContext, Script } from "vm"
|
||||
import { ChallengeService } from "./candle/challengeService"
|
||||
import { getFlag } from "./flags"
|
||||
import { unpack } from "msgpackr"
|
||||
import { ChallengePackage } from "./types/challenges"
|
||||
import { ChallengePackage, SavedChallengeGroup } from "./types/challenges"
|
||||
import { promisify } from "util"
|
||||
import { brotliDecompress } from "zlib"
|
||||
import assert from "assert"
|
||||
@ -920,6 +922,42 @@ export class Controller {
|
||||
},
|
||||
)
|
||||
|
||||
//Get all global challenges and register a simplified version of them
|
||||
{
|
||||
const globalChallenges: RegistryChallenge[] = (
|
||||
getConfig(
|
||||
"GlobalChallenges",
|
||||
true,
|
||||
) as CompiledChallengeRuntimeData[]
|
||||
).map((e) => {
|
||||
const tags = e.Challenge.Tags || []
|
||||
tags.push("global")
|
||||
|
||||
//NOTE: Treat all other fields as undefined
|
||||
return <RegistryChallenge>{
|
||||
Id: e.Challenge.Id,
|
||||
Tags: tags,
|
||||
Name: e.Challenge.Name,
|
||||
ImageName: e.Challenge.ImageName,
|
||||
Description: e.Challenge.Description,
|
||||
Definition: e.Challenge.Definition,
|
||||
Xp: e.Challenge.Xp,
|
||||
}
|
||||
})
|
||||
|
||||
this._handleChallengeResources({
|
||||
groups: [
|
||||
<SavedChallengeGroup>{
|
||||
CategoryId: "global",
|
||||
Challenges: globalChallenges,
|
||||
},
|
||||
],
|
||||
meta: {
|
||||
Location: "GLOBAL",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Load mastery resources
|
||||
const masteryDirectory = join(
|
||||
PEACOCK_DEV ? process.cwd() : __dirname,
|
||||
|
@ -104,9 +104,41 @@ export function getUserData(
|
||||
}
|
||||
}
|
||||
|
||||
//NOTE: ProfileLevel always starts at 1
|
||||
if (data?.Extensions?.progression?.PlayerProfileXP?.ProfileLevel === 0) {
|
||||
data.Extensions.progression.PlayerProfileXP.ProfileLevel = 1
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Only attempt to load a user's profile if it hasn't been loaded yet
|
||||
*
|
||||
* @param userId The user's ID.
|
||||
* @param gameVersion The game's version.
|
||||
*/
|
||||
export async function cheapLoadUserData(
|
||||
userId: string,
|
||||
gameVersion: GameVersion,
|
||||
): Promise<void> {
|
||||
if (!userId || !gameVersion) {
|
||||
return
|
||||
}
|
||||
|
||||
const userProfile = asyncGuard.getProfile(`${userId}.${gameVersion}`)
|
||||
|
||||
if (userProfile) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await loadUserData(userId, gameVersion)
|
||||
} catch (e) {
|
||||
log(LogLevel.DEBUG, "Unable to load profile information.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a user's profile data.
|
||||
*
|
||||
|
@ -31,7 +31,7 @@ import {
|
||||
Seconds,
|
||||
ServerToClientEvent,
|
||||
} from "./types/types"
|
||||
import { contractTypes, extractToken, ServerVer } from "./utils"
|
||||
import { contractTypes, extractToken, gameDifficulty, ServerVer } from "./utils"
|
||||
import { json as jsonMiddleware } from "body-parser"
|
||||
import { log, LogLevel } from "./loggingInterop"
|
||||
import { getUserData, writeUserData } from "./databaseHandler"
|
||||
@ -47,6 +47,7 @@ import {
|
||||
AmbientChangedC2SEvent,
|
||||
BodyHiddenC2SEvent,
|
||||
ContractStartC2SEvent,
|
||||
Evergreen_Payout_DataC2SEvent,
|
||||
HeroSpawn_LocationC2SEvent,
|
||||
ItemDroppedC2SEvent,
|
||||
ItemPickedUpC2SEvent,
|
||||
@ -471,7 +472,25 @@ function saveEvents(
|
||||
const processed: string[] = []
|
||||
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
|
||||
events.forEach((event) => {
|
||||
const session = contractSessions.get(event.ContractSessionId)
|
||||
let session = contractSessions.get(event.ContractSessionId)
|
||||
|
||||
if (!session) {
|
||||
log(
|
||||
LogLevel.WARN,
|
||||
"Creating a fake session to avoid problems... scoring will not work!",
|
||||
)
|
||||
|
||||
newSession(
|
||||
event.ContractSessionId,
|
||||
event.ContractId,
|
||||
req.jwt.unique_name,
|
||||
gameDifficulty.normal,
|
||||
req.gameVersion,
|
||||
false,
|
||||
)
|
||||
|
||||
session = contractSessions.get(event.ContractSessionId)
|
||||
}
|
||||
|
||||
if (
|
||||
!session ||
|
||||
@ -562,6 +581,18 @@ function saveEvents(
|
||||
|
||||
if (handleMultiplayerEvent(event, session)) {
|
||||
processed.push(event.Name)
|
||||
response.push(process.hrtime.bigint().toString())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.Name.startsWith("ScoringScreenEndState_")) {
|
||||
session.evergreen.scoringScreenEndState = event.Name
|
||||
|
||||
processed.push(event.Name)
|
||||
response.push(process.hrtime.bigint().toString())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.Name) {
|
||||
@ -760,6 +791,11 @@ function saveEvents(
|
||||
contract.Metadata.CpdId,
|
||||
)
|
||||
break
|
||||
case "Evergreen_Payout_Data":
|
||||
session.evergreen.payout = (<Evergreen_Payout_DataC2SEvent>(
|
||||
event
|
||||
)).Value.Total_Payout
|
||||
break
|
||||
// Sinkhole events we don't care about
|
||||
case "ItemPickedUp":
|
||||
log(
|
||||
|
@ -51,5 +51,11 @@ export async function getCpd(
|
||||
return defaultCPD
|
||||
}
|
||||
|
||||
//NOTE: Update the EvergreenLevel with the latest Mastery Level
|
||||
//TODO: Get rid of hard-coded values
|
||||
userData.Extensions.CPD[cpdID]["EvergreenLevel"] =
|
||||
userData.Extensions.progression.Locations["location_parent_snug"]
|
||||
?.Level || 1
|
||||
|
||||
return userData.Extensions.CPD[cpdID]
|
||||
}
|
||||
|
@ -80,6 +80,7 @@ import { multiplayerRouter } from "./multiplayer/multiplayerService"
|
||||
import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData"
|
||||
import { pack, unpack } from "msgpackr"
|
||||
import { liveSplitManager } from "./livesplit/liveSplitManager"
|
||||
import { cheapLoadUserData } from "./databaseHandler"
|
||||
|
||||
// welcome to the bleeding edge
|
||||
setFlagsFromString("--harmony")
|
||||
@ -356,7 +357,18 @@ app.use(
|
||||
|
||||
next()
|
||||
}),
|
||||
)
|
||||
).use(async (req: RequestWithJwt, _res, next): Promise<void> => {
|
||||
if (!req.jwt) {
|
||||
next()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// make sure the userdata is always loaded if a proper jwt token is available
|
||||
await cheapLoadUserData(req.jwt.unique_name, req.gameVersion)
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
function generateBlobConfig(req: RequestWithJwt) {
|
||||
return {
|
||||
|
@ -20,6 +20,7 @@ import { Response, Router } from "express"
|
||||
import {
|
||||
contractCreationTutorialId,
|
||||
gameDifficulty,
|
||||
getMaxProfileLevel,
|
||||
PEACOCKVERSTRING,
|
||||
unlockOrderComparer,
|
||||
uuidRegex,
|
||||
@ -322,7 +323,7 @@ menuDataRouter.get("/Hub", (req: RequestWithJwt, res) => {
|
||||
XP: userdata.Extensions.progression.PlayerProfileXP.Total,
|
||||
Level: userdata.Extensions.progression.PlayerProfileXP
|
||||
.ProfileLevel,
|
||||
MaxLevel: 7500,
|
||||
MaxLevel: getMaxProfileLevel(req.gameVersion),
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -1837,7 +1838,7 @@ menuDataRouter.get("/GetPlayerProfileXpData", (req: RequestWithJwt, res) => {
|
||||
XP: userData.Extensions.progression.PlayerProfileXP.Total,
|
||||
Level: userData.Extensions.progression.PlayerProfileXP
|
||||
.ProfileLevel,
|
||||
MaxLevel: 7500,
|
||||
MaxLevel: getMaxProfileLevel(req.gameVersion),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -35,6 +35,7 @@ import { getUserData, writeUserData } from "../databaseHandler"
|
||||
import {
|
||||
fastClone,
|
||||
getDefaultSuitFor,
|
||||
getMaxProfileLevel,
|
||||
nilUuid,
|
||||
unlockOrderComparer,
|
||||
} from "../utils"
|
||||
@ -471,7 +472,7 @@ export async function planningView(
|
||||
XP: userData.Extensions.progression.PlayerProfileXP.Total,
|
||||
Level: userData.Extensions.progression.PlayerProfileXP
|
||||
.ProfileLevel,
|
||||
MaxLevel: 7500,
|
||||
MaxLevel: getMaxProfileLevel(req.gameVersion),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
@ -18,7 +18,13 @@
|
||||
|
||||
import { Router } from "express"
|
||||
import path from "path"
|
||||
import { castUserProfile, nilUuid, uuidRegex } from "./utils"
|
||||
import {
|
||||
castUserProfile,
|
||||
getMaxProfileLevel,
|
||||
nilUuid,
|
||||
uuidRegex,
|
||||
XP_PER_LEVEL,
|
||||
} from "./utils"
|
||||
import { json as jsonMiddleware } from "body-parser"
|
||||
import { getPlatformEntitlements } from "./platformEntitlements"
|
||||
import { contractSessions, newSession } from "./eventHandler"
|
||||
@ -542,36 +548,20 @@ profileRouter.post(
|
||||
}
|
||||
|
||||
for (const challenge of challenges) {
|
||||
// TODO: Add actual support for shortcut challenges
|
||||
if (challenge.Challenge.Tags?.includes("shortcut")) {
|
||||
challenge.Progression = {
|
||||
challenge.Progression = Object.assign(
|
||||
{
|
||||
ChallengeId: challenge.Challenge.Id,
|
||||
ProfileId: req.jwt.unique_name,
|
||||
Completed: true,
|
||||
Ticked: true,
|
||||
State: {
|
||||
CurrentState: "Success",
|
||||
},
|
||||
// @ts-expect-error typescript hates dates
|
||||
CompletedAt: new Date(new Date() - 10).toISOString(),
|
||||
Completed: false,
|
||||
State: {},
|
||||
ETag: `W/"datetime'${encodeURIComponent(
|
||||
new Date().toISOString(),
|
||||
)}'"`,
|
||||
CompletedAt: null,
|
||||
MustBeSaved: false,
|
||||
}
|
||||
} else {
|
||||
challenge.Progression = Object.assign(
|
||||
{
|
||||
ChallengeId: challenge.Challenge.Id,
|
||||
ProfileId: req.jwt.unique_name,
|
||||
Completed: false,
|
||||
State: {},
|
||||
ETag: `W/"datetime'${encodeURIComponent(
|
||||
new Date().toISOString(),
|
||||
)}'"`,
|
||||
CompletedAt: null,
|
||||
MustBeSaved: false,
|
||||
},
|
||||
challenge.Progression,
|
||||
)
|
||||
}
|
||||
},
|
||||
challenge.Progression,
|
||||
)
|
||||
}
|
||||
|
||||
res.json(challenges)
|
||||
@ -595,8 +585,8 @@ profileRouter.post(
|
||||
Location: [0],
|
||||
PlayerProfile: {
|
||||
Version: 1,
|
||||
XpPerLevel: 6000,
|
||||
MaxLevel: 7500,
|
||||
XpPerLevel: XP_PER_LEVEL,
|
||||
MaxLevel: getMaxProfileLevel(req.gameVersion),
|
||||
},
|
||||
},
|
||||
})
|
||||
@ -817,6 +807,13 @@ async function loadSession(
|
||||
}
|
||||
// Update challenge progression with the user's latest progression data
|
||||
for (const cid in sessionData.challengeContexts) {
|
||||
// Make sure the ChallengeProgression is available, otherwise loading might fail!
|
||||
userData.Extensions.ChallengeProgression[cid] ??= {
|
||||
State: {},
|
||||
Completed: false,
|
||||
Ticked: false,
|
||||
}
|
||||
|
||||
const scope =
|
||||
controller.challengeService.getChallengeById(cid).Definition.Scope
|
||||
if (
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -40,10 +40,15 @@ export interface SavedChallenge {
|
||||
ParentLocationId: string
|
||||
Type: "Hit" | string
|
||||
RuntimeType: "contract" | string
|
||||
Xp: number
|
||||
XpModifier?: unknown
|
||||
DifficultyLevels: string[]
|
||||
Definition: MissionManifestObjective["Definition"] & {
|
||||
Scope: ContextScopedStorageLocation
|
||||
Repeatable?: {
|
||||
Base: number
|
||||
Delta: number
|
||||
}
|
||||
}
|
||||
Tags: string[]
|
||||
InclusionData?: InclusionData
|
||||
|
@ -253,3 +253,7 @@ export type Dart_HitC2SEvent = ClientToServerEvent<{
|
||||
ActorType: number
|
||||
Sedative: "" | string
|
||||
}>
|
||||
|
||||
export type Evergreen_Payout_DataC2SEvent = ClientToServerEvent<{
|
||||
Total_Payout: number
|
||||
}>
|
||||
|
127
components/types/score.ts
Normal file
127
components/types/score.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import { Playstyle, ScoringBonus, ScoringHeadline } from "./scoring"
|
||||
import { CompletionData, Seconds, Unlockable } from "./types"
|
||||
|
||||
export interface CalculateXpResult {
|
||||
completedChallenges: MissionEndChallenge[]
|
||||
xp: number
|
||||
}
|
||||
|
||||
export interface CalculateScoreResult {
|
||||
stars: number
|
||||
scoringHeadlines: ScoringHeadline[]
|
||||
awardedBonuses: ScoringBonus[]
|
||||
failedBonuses: ScoringBonus[]
|
||||
achievedMasteries: MissionEndAchievedMastery[]
|
||||
silentAssassin: boolean
|
||||
score: number
|
||||
scoreWithBonus: number
|
||||
}
|
||||
|
||||
export interface MissionEndChallenge {
|
||||
ChallengeId: string
|
||||
ChallengeTags: string[]
|
||||
ChallengeName: string
|
||||
ChallengeImageUrl: string
|
||||
ChallengeDescription: string
|
||||
XPGain: number
|
||||
IsGlobal: boolean
|
||||
IsActionReward: boolean
|
||||
Drops: string[]
|
||||
}
|
||||
|
||||
export interface MissionEndDrop {
|
||||
Unlockable: Unlockable
|
||||
}
|
||||
|
||||
export interface MissionEndAchievedMastery {
|
||||
score: number
|
||||
RatioParts: number
|
||||
RatioTotal: number
|
||||
Id: string
|
||||
BaseScore: number
|
||||
}
|
||||
|
||||
export interface MissionEndEvergreen {
|
||||
Payout: number
|
||||
EndStateEventName?: string
|
||||
PayoutsCompleted: MissionEndEvergreenPayout[]
|
||||
PayoutsFailed: MissionEndEvergreenPayout[]
|
||||
}
|
||||
|
||||
export interface MissionEndEvergreenPayout {
|
||||
Name: string
|
||||
Payout: number
|
||||
IsPrestige: boolean
|
||||
}
|
||||
|
||||
export interface MissionEndResponse {
|
||||
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
|
||||
}
|
||||
Challenges: MissionEndChallenge[]
|
||||
Drops: MissionEndDrop[]
|
||||
OpportunityRewards: unknown[] //?
|
||||
CompletionData: CompletionData
|
||||
ChallengeCompletion: {
|
||||
ChallengesCount: number
|
||||
CompletedChallengesCount: number
|
||||
}
|
||||
ContractChallengeCompletion: {
|
||||
ChallengesCount: number
|
||||
CompletedChallengesCount: number
|
||||
}
|
||||
OpportunityStatistics: {
|
||||
Count: number
|
||||
Completed: number
|
||||
}
|
||||
LocationCompletionPercent: number
|
||||
}
|
||||
ScoreOverview: {
|
||||
XP: number
|
||||
Level: number
|
||||
Completion: number
|
||||
XPGain: number
|
||||
ChallengesCompleted: number
|
||||
LocationHideProgression: boolean
|
||||
ProdileId1?: string
|
||||
stars: number
|
||||
ScoreDetails: {
|
||||
Headlines: ScoringHeadline[]
|
||||
}
|
||||
ContractScore: {
|
||||
Total: number
|
||||
AchievedMasteries: MissionEndAchievedMastery[]
|
||||
AwardedBonuses: ScoringBonus[]
|
||||
TotalNoMultipliers: number
|
||||
TimeUsedSecs: Seconds
|
||||
StarCount: number
|
||||
FailedBonuses: ScoringBonus[]
|
||||
SilentAssassin: boolean
|
||||
}
|
||||
SilentAssassin: boolean
|
||||
NewRank: number
|
||||
RankCount: number
|
||||
Rank: number
|
||||
FriendsRankCount: number
|
||||
FriendsRank: number
|
||||
IsPartOfTopScores: boolean
|
||||
PlayStyle?: Playstyle
|
||||
IsNewBestScore: boolean
|
||||
IsNewBestTime: boolean
|
||||
IsNewBestStars: boolean
|
||||
Evergreen?: MissionEndEvergreen
|
||||
}
|
||||
}
|
@ -262,8 +262,18 @@ export interface ContractSession {
|
||||
context: unknown
|
||||
state: string
|
||||
timers: Timer[]
|
||||
timesCompleted: number
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Session Evergreen details.
|
||||
*
|
||||
* @since v6.0.0
|
||||
*/
|
||||
evergreen?: {
|
||||
payout: number
|
||||
scoringScreenEndState: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -123,8 +123,69 @@ export function extractToken(
|
||||
next?.("router")
|
||||
}
|
||||
|
||||
export const DEFAULT_MASTERY_MAXLEVEL = 20
|
||||
export const XP_PER_LEVEL = 6000
|
||||
|
||||
export function getMaxProfileLevel(gameVersion: GameVersion): number {
|
||||
if (gameVersion === "h3") {
|
||||
return 7500
|
||||
}
|
||||
|
||||
return 5000
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the level for the given XP based on XP_PER_LEVEL.
|
||||
* Minimum level returned is 1.
|
||||
*/
|
||||
export function levelForXp(xp: number): number {
|
||||
return Math.floor(xp / XP_PER_LEVEL) + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the required XP for the given level based on XP_PER_LEVEL.
|
||||
* Minimum XP returned is 0.
|
||||
*/
|
||||
export function xpRequiredForLevel(level: number): number {
|
||||
return level * 6000 - 6000
|
||||
return Math.max(0, (level - 1) * XP_PER_LEVEL)
|
||||
}
|
||||
|
||||
export const EVERGREEN_LEVEL_INFO: number[] = [
|
||||
0, 5000, 10000, 17000, 24000, 31000, 38000, 45000, 52000, 61000, 70000,
|
||||
79000, 88000, 97000, 106000, 115000, 124000, 133000, 142000, 154000, 166000,
|
||||
178000, 190000, 202000, 214000, 226000, 238000, 250000, 262000, 280000,
|
||||
298000, 316000, 334000, 352000, 370000, 388000, 406000, 424000, 442000,
|
||||
468000, 494000, 520000, 546000, 572000, 598000, 624000, 650000, 676000,
|
||||
702000, 736000, 770000, 804000, 838000, 872000, 906000, 940000, 974000,
|
||||
1008000, 1042000, 1082000, 1122000, 1162000, 1202000, 1242000, 1282000,
|
||||
1322000, 1362000, 1402000, 1442000, 1492000, 1542000, 1592000, 1642000,
|
||||
1692000, 1742000, 1792000, 1842000, 1892000, 1942000, 2002000, 2062000,
|
||||
2122000, 2182000, 2242000, 2302000, 2362000, 2422000, 2482000, 2542000,
|
||||
2692000, 2842000, 2992000, 3142000, 3292000, 3442000, 3592000, 3742000,
|
||||
3892000, 4042000, 4192000,
|
||||
]
|
||||
|
||||
export function evergreenLevelForXp(xp: number): number {
|
||||
for (let i = 1; i < EVERGREEN_LEVEL_INFO.length; i++) {
|
||||
if (xp >= EVERGREEN_LEVEL_INFO[i]) {
|
||||
continue
|
||||
}
|
||||
|
||||
return i
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
export function xpRequiredForEvergreenLevel(level: number): number {
|
||||
return EVERGREEN_LEVEL_INFO[level - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamps the given value between a minimum and maximum value
|
||||
*/
|
||||
export function clampValue(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(value, max))
|
||||
}
|
||||
|
||||
export function castUserProfile(profile: UserProfile): UserProfile {
|
||||
|
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user