1
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:
Lennard Fonteijn 2023-03-18 18:35:26 +01:00 committed by Reece Dunham
parent c7d676d0af
commit ba9b799abe
18 changed files with 1684 additions and 1777 deletions

View File

@ -32,10 +32,11 @@ import type {
} from "../types/types" } from "../types/types"
import { getUserData, writeUserData } from "../databaseHandler" import { getUserData, writeUserData } from "../databaseHandler"
import { Controller } from "../controller" import { controller, Controller } from "../controller"
import { import {
generateCompletionData, generateCompletionData,
generateUserCentric, generateUserCentric,
getSubLocationByName,
getSubLocationFromContract, getSubLocationFromContract,
} from "../contracts/dataGen" } from "../contracts/dataGen"
import { log, LogLevel } from "../loggingInterop" import { log, LogLevel } from "../loggingInterop"
@ -48,11 +49,21 @@ import {
HandleEventOptions, HandleEventOptions,
} from "@peacockproject/statemachine-parser" } from "@peacockproject/statemachine-parser"
import { SavedChallengeGroup } from "../types/challenges" import { SavedChallengeGroup } from "../types/challenges"
import { fastClone } from "../utils" import {
clampValue,
DEFAULT_MASTERY_MAXLEVEL,
evergreenLevelForXp,
fastClone,
getMaxProfileLevel,
levelForXp,
xpRequiredForEvergreenLevel,
xpRequiredForLevel,
} from "../utils"
import { import {
ChallengeFilterOptions, ChallengeFilterOptions,
ChallengeFilterType, ChallengeFilterType,
filterChallenge, filterChallenge,
inclusionDataCheck,
mergeSavedChallengeGroups, mergeSavedChallengeGroups,
} from "./challengeHelpers" } from "./challengeHelpers"
import assert from "assert" import assert from "assert"
@ -324,6 +335,11 @@ export class ChallengeService extends ChallengeRegistry {
let challenges: [string, RegistryChallenge[]][] = [] let challenges: [string, RegistryChallenge[]][] = []
for (const groupId of this.groups.get(location).keys()) { 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) const groupContents = this.getGroupContentByIdLoc(groupId, location)
if (groupContents) { if (groupContents) {
let groupChallenges: RegistryChallenge[] | string[] = [ let groupChallenges: RegistryChallenge[] | string[] = [
@ -429,12 +445,42 @@ export class ChallengeService extends ChallengeRegistry {
gameVersion, 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) const profile = getUserData(session.userId, session.gameVersion)
for (const group of Object.keys(challengeGroups)) { for (const group of Object.keys(challengeGroups)) {
for (const challenge of challengeGroups[group]) { for (const challenge of challengeGroups[group]) {
const isDone = this.fastGetIsCompleted(profile, challenge.Id) 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", // For challenges with scopes being "profile" or "hit",
// update challenge progression with the user's progression data // update challenge progression with the user's progression data
const ctx = const ctx =
@ -451,6 +497,7 @@ export class ChallengeService extends ChallengeRegistry {
context: ctx, context: ctx,
state: isDone ? "Success" : "Start", state: isDone ? "Success" : "Start",
timers: [], timers: [],
timesCompleted: 0,
} }
} }
} }
@ -488,6 +535,8 @@ export class ChallengeService extends ChallengeRegistry {
currentState: data.state, currentState: data.state,
timers: data.timers, timers: data.timers,
timestamp: event.Timestamp, timestamp: event.Timestamp,
//logger: (category, message) =>
// log(LogLevel.DEBUG, `[${category}] ${message}`),
} }
const previousState = data.state const previousState = data.state
@ -499,6 +548,7 @@ export class ChallengeService extends ChallengeRegistry {
event.Value, event.Value,
options, options,
) )
// For challenges with scopes being "profile" or "hit", // For challenges with scopes being "profile" or "hit",
// save challenge progression to the user's progression data // save challenge progression to the user's progression data
if ( if (
@ -519,6 +569,7 @@ export class ChallengeService extends ChallengeRegistry {
if (previousState !== "Success" && result.state === "Success") { if (previousState !== "Success" && result.state === "Success") {
this.onChallengeCompleted( this.onChallengeCompleted(
session,
session.userId, session.userId,
session.gameVersion, session.gameVersion,
challenge, challenge,
@ -991,6 +1042,7 @@ export class ChallengeService extends ChallengeRegistry {
* @param gameVersion The game version. * @param gameVersion The game version.
*/ */
public tryToCompleteChallenge( public tryToCompleteChallenge(
session: ContractSession,
challengeId: string, challengeId: string,
userData: UserProfile, userData: UserProfile,
parentId: string, parentId: string,
@ -1037,6 +1089,7 @@ export class ChallengeService extends ChallengeRegistry {
} }
this.onChallengeCompleted( this.onChallengeCompleted(
session,
userData.Id, userData.Id,
gameVersion, gameVersion,
this.getChallengeById(challengeId), this.getChallengeById(challengeId),
@ -1045,6 +1098,7 @@ export class ChallengeService extends ChallengeRegistry {
} }
private onChallengeCompleted( private onChallengeCompleted(
session: ContractSession,
userId: string, userId: string,
gameVersion: GameVersion, gameVersion: GameVersion,
challenge: RegistryChallenge, challenge: RegistryChallenge,
@ -1061,15 +1115,35 @@ export class ChallengeService extends ChallengeRegistry {
const userData = getUserData(userId, gameVersion) 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] ??= { userData.Extensions.ChallengeProgression[challenge.Id] ??= {
State: {}, State: {},
Completed: false, Completed: false,
Ticked: 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) 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 // Check if completing this challenge also completes any dependency trees depending on it
for (const depTreeId of this._dependencyTree.keys()) { for (const depTreeId of this._dependencyTree.keys()) {
this.tryToCompleteChallenge( this.tryToCompleteChallenge(
session,
depTreeId, depTreeId,
userData, userData,
challenge.Id, 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
}
} }

View File

@ -27,7 +27,12 @@ import {
MasteryPackage, MasteryPackage,
} from "../types/mastery" } from "../types/mastery"
import { CompletionData, GameVersion, Unlockable } from "../types/types" 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 { export class MasteryService {
private masteryData: Map<string, MasteryPackage> = new Map() private masteryData: Map<string, MasteryPackage> = new Map()
@ -81,13 +86,13 @@ export class MasteryService {
gameVersion: GameVersion, gameVersion: GameVersion,
userId: string, userId: string,
): CompletionData { ): CompletionData {
if (!this.masteryData.has(locationParentId)) {
return undefined
}
//Get the mastery data //Get the mastery data
const masteryData: MasteryPackage = const masteryData: MasteryPackage =
this.masteryData.get(locationParentId) this.getMasteryPackage(locationParentId)
if (!masteryData) {
return undefined
}
//Get the user profile //Get the user profile
const userProfile = getUserData(userId, gameVersion) const userProfile = getUserData(userId, gameVersion)
@ -107,11 +112,12 @@ export class MasteryService {
lowerCaseLocationParentId lowerCaseLocationParentId
] ]
const maxLevel = masteryData.MaxLevel || 20 const maxLevel = masteryData.MaxLevel || DEFAULT_MASTERY_MAXLEVEL
const nextLevel: number = Math.max( const nextLevel: number = clampValue(
0, locationData.Level + 1,
Math.min(locationData.Level + 1, maxLevel), 1,
maxLevel,
) )
const nextLevelXp: number = xpRequiredForLevel(nextLevel) const nextLevelXp: number = xpRequiredForLevel(nextLevel)
@ -119,7 +125,8 @@ export class MasteryService {
Level: locationData.Level, Level: locationData.Level,
MaxLevel: maxLevel, MaxLevel: maxLevel,
XP: locationData.Xp, XP: locationData.Xp,
Completion: locationData.Xp / nextLevelXp, Completion:
(XP_PER_LEVEL - (nextLevelXp - locationData.Xp)) / XP_PER_LEVEL,
XpLeft: nextLevelXp - locationData.Xp, XpLeft: nextLevelXp - locationData.Xp,
Id: masteryData.Id, Id: masteryData.Id,
SubLocationId: subLocationId, 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( private getMasteryData(
locationParentId: string, locationParentId: string,
gameVersion: GameVersion, gameVersion: GameVersion,
userId: string, userId: string,
): MasteryData[] { ): MasteryData[] {
if (!this.masteryData.has(locationParentId)) {
return []
}
//Get the mastery data //Get the mastery data
const masteryData: MasteryPackage = const masteryData: MasteryPackage =
this.masteryData.get(locationParentId) this.getMasteryPackage(locationParentId)
if (masteryData.Drops.length === 0) { if (!masteryData || masteryData.Drops.length === 0) {
return [] return []
} }

View File

@ -29,7 +29,11 @@ import { controller, preserveContracts } from "../controller"
import { createLocationsData } from "../menus/destinations" import { createLocationsData } from "../menus/destinations"
import { userAuths } from "../officialServerAuth" import { userAuths } from "../officialServerAuth"
import { log, LogLevel } from "../loggingInterop" import { log, LogLevel } from "../loggingInterop"
import { getRemoteService, contractCreationTutorialId } from "../utils" import {
getRemoteService,
contractCreationTutorialId,
getMaxProfileLevel,
} from "../utils"
export function contractsModeHome(req: RequestWithJwt, res: Response): void { export function contractsModeHome(req: RequestWithJwt, res: Response): void {
const contractsHomeTemplate = getConfig("ContractsTemplate", false) const contractsHomeTemplate = getConfig("ContractsTemplate", false)
@ -58,7 +62,7 @@ export function contractsModeHome(req: RequestWithJwt, res: Response): void {
XP: userData.Extensions.progression.PlayerProfileXP.Total, XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel, .ProfileLevel,
MaxLevel: 7500, MaxLevel: getMaxProfileLevel(req.gameVersion),
}, },
}, },
}) })

View File

@ -28,6 +28,7 @@ import {
import type { import type {
Campaign, Campaign,
ClientToServerEvent, ClientToServerEvent,
CompiledChallengeRuntimeData,
ContractSession, ContractSession,
GameVersion, GameVersion,
GenSingleMissionFunc, GenSingleMissionFunc,
@ -36,6 +37,7 @@ import type {
MissionManifest, MissionManifest,
PeacockLocationsData, PeacockLocationsData,
PlayNextGetCampaignsHookReturn, PlayNextGetCampaignsHookReturn,
RegistryChallenge,
RequestWithJwt, RequestWithJwt,
S2CEventWithTimestamp, S2CEventWithTimestamp,
SMFLastDeploy, SMFLastDeploy,
@ -81,7 +83,7 @@ import { createContext, Script } from "vm"
import { ChallengeService } from "./candle/challengeService" import { ChallengeService } from "./candle/challengeService"
import { getFlag } from "./flags" import { getFlag } from "./flags"
import { unpack } from "msgpackr" import { unpack } from "msgpackr"
import { ChallengePackage } from "./types/challenges" import { ChallengePackage, SavedChallengeGroup } from "./types/challenges"
import { promisify } from "util" import { promisify } from "util"
import { brotliDecompress } from "zlib" import { brotliDecompress } from "zlib"
import assert from "assert" 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 // Load mastery resources
const masteryDirectory = join( const masteryDirectory = join(
PEACOCK_DEV ? process.cwd() : __dirname, PEACOCK_DEV ? process.cwd() : __dirname,

View File

@ -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 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. * Loads a user's profile data.
* *

View File

@ -31,7 +31,7 @@ import {
Seconds, Seconds,
ServerToClientEvent, ServerToClientEvent,
} from "./types/types" } from "./types/types"
import { contractTypes, extractToken, ServerVer } from "./utils" import { contractTypes, extractToken, gameDifficulty, ServerVer } from "./utils"
import { json as jsonMiddleware } from "body-parser" import { json as jsonMiddleware } from "body-parser"
import { log, LogLevel } from "./loggingInterop" import { log, LogLevel } from "./loggingInterop"
import { getUserData, writeUserData } from "./databaseHandler" import { getUserData, writeUserData } from "./databaseHandler"
@ -47,6 +47,7 @@ import {
AmbientChangedC2SEvent, AmbientChangedC2SEvent,
BodyHiddenC2SEvent, BodyHiddenC2SEvent,
ContractStartC2SEvent, ContractStartC2SEvent,
Evergreen_Payout_DataC2SEvent,
HeroSpawn_LocationC2SEvent, HeroSpawn_LocationC2SEvent,
ItemDroppedC2SEvent, ItemDroppedC2SEvent,
ItemPickedUpC2SEvent, ItemPickedUpC2SEvent,
@ -471,7 +472,25 @@ function saveEvents(
const processed: string[] = [] const processed: string[] = []
const userData = getUserData(req.jwt.unique_name, req.gameVersion) const userData = getUserData(req.jwt.unique_name, req.gameVersion)
events.forEach((event) => { 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 ( if (
!session || !session ||
@ -562,6 +581,18 @@ function saveEvents(
if (handleMultiplayerEvent(event, session)) { if (handleMultiplayerEvent(event, session)) {
processed.push(event.Name) 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) { switch (event.Name) {
@ -760,6 +791,11 @@ function saveEvents(
contract.Metadata.CpdId, contract.Metadata.CpdId,
) )
break break
case "Evergreen_Payout_Data":
session.evergreen.payout = (<Evergreen_Payout_DataC2SEvent>(
event
)).Value.Total_Payout
break
// Sinkhole events we don't care about // Sinkhole events we don't care about
case "ItemPickedUp": case "ItemPickedUp":
log( log(

View File

@ -51,5 +51,11 @@ export async function getCpd(
return defaultCPD 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] return userData.Extensions.CPD[cpdID]
} }

View File

@ -80,6 +80,7 @@ import { multiplayerRouter } from "./multiplayer/multiplayerService"
import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData" import { multiplayerMenuDataRouter } from "./multiplayer/multiplayerMenuData"
import { pack, unpack } from "msgpackr" import { pack, unpack } from "msgpackr"
import { liveSplitManager } from "./livesplit/liveSplitManager" import { liveSplitManager } from "./livesplit/liveSplitManager"
import { cheapLoadUserData } from "./databaseHandler"
// welcome to the bleeding edge // welcome to the bleeding edge
setFlagsFromString("--harmony") setFlagsFromString("--harmony")
@ -356,7 +357,18 @@ app.use(
next() 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) { function generateBlobConfig(req: RequestWithJwt) {
return { return {

View File

@ -20,6 +20,7 @@ import { Response, Router } from "express"
import { import {
contractCreationTutorialId, contractCreationTutorialId,
gameDifficulty, gameDifficulty,
getMaxProfileLevel,
PEACOCKVERSTRING, PEACOCKVERSTRING,
unlockOrderComparer, unlockOrderComparer,
uuidRegex, uuidRegex,
@ -322,7 +323,7 @@ menuDataRouter.get("/Hub", (req: RequestWithJwt, res) => {
XP: userdata.Extensions.progression.PlayerProfileXP.Total, XP: userdata.Extensions.progression.PlayerProfileXP.Total,
Level: userdata.Extensions.progression.PlayerProfileXP Level: userdata.Extensions.progression.PlayerProfileXP
.ProfileLevel, .ProfileLevel,
MaxLevel: 7500, MaxLevel: getMaxProfileLevel(req.gameVersion),
}, },
}, },
}) })
@ -1837,7 +1838,7 @@ menuDataRouter.get("/GetPlayerProfileXpData", (req: RequestWithJwt, res) => {
XP: userData.Extensions.progression.PlayerProfileXP.Total, XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel, .ProfileLevel,
MaxLevel: 7500, MaxLevel: getMaxProfileLevel(req.gameVersion),
}, },
}, },
}) })

View File

@ -35,6 +35,7 @@ import { getUserData, writeUserData } from "../databaseHandler"
import { import {
fastClone, fastClone,
getDefaultSuitFor, getDefaultSuitFor,
getMaxProfileLevel,
nilUuid, nilUuid,
unlockOrderComparer, unlockOrderComparer,
} from "../utils" } from "../utils"
@ -471,7 +472,7 @@ export async function planningView(
XP: userData.Extensions.progression.PlayerProfileXP.Total, XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel, .ProfileLevel,
MaxLevel: 7500, MaxLevel: getMaxProfileLevel(req.gameVersion),
}, },
}, },
}) })

View File

@ -18,7 +18,13 @@
import { Router } from "express" import { Router } from "express"
import path from "path" 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 { json as jsonMiddleware } from "body-parser"
import { getPlatformEntitlements } from "./platformEntitlements" import { getPlatformEntitlements } from "./platformEntitlements"
import { contractSessions, newSession } from "./eventHandler" import { contractSessions, newSession } from "./eventHandler"
@ -542,36 +548,20 @@ profileRouter.post(
} }
for (const challenge of challenges) { for (const challenge of challenges) {
// TODO: Add actual support for shortcut challenges challenge.Progression = Object.assign(
if (challenge.Challenge.Tags?.includes("shortcut")) { {
challenge.Progression = {
ChallengeId: challenge.Challenge.Id, ChallengeId: challenge.Challenge.Id,
ProfileId: req.jwt.unique_name, ProfileId: req.jwt.unique_name,
Completed: true, Completed: false,
Ticked: true, State: {},
State: { ETag: `W/"datetime'${encodeURIComponent(
CurrentState: "Success", new Date().toISOString(),
}, )}'"`,
// @ts-expect-error typescript hates dates CompletedAt: null,
CompletedAt: new Date(new Date() - 10).toISOString(),
MustBeSaved: false, MustBeSaved: false,
} },
} else { challenge.Progression,
challenge.Progression = Object.assign( )
{
ChallengeId: challenge.Challenge.Id,
ProfileId: req.jwt.unique_name,
Completed: false,
State: {},
ETag: `W/"datetime'${encodeURIComponent(
new Date().toISOString(),
)}'"`,
CompletedAt: null,
MustBeSaved: false,
},
challenge.Progression,
)
}
} }
res.json(challenges) res.json(challenges)
@ -595,8 +585,8 @@ profileRouter.post(
Location: [0], Location: [0],
PlayerProfile: { PlayerProfile: {
Version: 1, Version: 1,
XpPerLevel: 6000, XpPerLevel: XP_PER_LEVEL,
MaxLevel: 7500, MaxLevel: getMaxProfileLevel(req.gameVersion),
}, },
}, },
}) })
@ -817,6 +807,13 @@ async function loadSession(
} }
// Update challenge progression with the user's latest progression data // Update challenge progression with the user's latest progression data
for (const cid in sessionData.challengeContexts) { 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 = const scope =
controller.challengeService.getChallengeById(cid).Definition.Scope controller.challengeService.getChallengeById(cid).Definition.Scope
if ( if (

File diff suppressed because it is too large Load Diff

View File

@ -40,10 +40,15 @@ export interface SavedChallenge {
ParentLocationId: string ParentLocationId: string
Type: "Hit" | string Type: "Hit" | string
RuntimeType: "contract" | string RuntimeType: "contract" | string
Xp: number
XpModifier?: unknown XpModifier?: unknown
DifficultyLevels: string[] DifficultyLevels: string[]
Definition: MissionManifestObjective["Definition"] & { Definition: MissionManifestObjective["Definition"] & {
Scope: ContextScopedStorageLocation Scope: ContextScopedStorageLocation
Repeatable?: {
Base: number
Delta: number
}
} }
Tags: string[] Tags: string[]
InclusionData?: InclusionData InclusionData?: InclusionData

View File

@ -253,3 +253,7 @@ export type Dart_HitC2SEvent = ClientToServerEvent<{
ActorType: number ActorType: number
Sedative: "" | string Sedative: "" | string
}> }>
export type Evergreen_Payout_DataC2SEvent = ClientToServerEvent<{
Total_Payout: number
}>

127
components/types/score.ts Normal file
View 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
}
}

View File

@ -262,8 +262,18 @@ export interface ContractSession {
context: unknown context: unknown
state: string state: string
timers: Timer[] timers: Timer[]
timesCompleted: number
} }
} }
/**
* Session Evergreen details.
*
* @since v6.0.0
*/
evergreen?: {
payout: number
scoringScreenEndState: string
}
} }
/** /**

View File

@ -123,8 +123,69 @@ export function extractToken(
next?.("router") 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 { 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 { export function castUserProfile(profile: UserProfile): UserProfile {

File diff suppressed because it is too large Load Diff