1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-22 22:12:45 +01:00
Peacock/components/scoreHandler.ts
Anthony Fuller 46052c7b0e
Multi-Version Mastery and Sniper Scoring (#270)
* Add multi-version mastery files

* Add pro1 unlocks to legacy allunlockables

* Add 47's suit to scpc all unlockables

* Add and remove various configs

* Remove some useless promises

* Fix scpc hub

* Fix issue with user profile saving

* Fix scpc issues for hub

* Add singleplayer/multiplayer sniper

* A great many things

- Add multi-version mastery
- Improve sniper mastery support
- Improve general H2016 support

* Fix some warnings

* Fix pro1 mastery on destination screens

* Remove entP from createInventory, lock/unlock pro1 accordingly

* Remove JSDoc entP parameter from createInventory

* Remove difficultyunlocks from safehouse pages

* Add versioned user profiles

* Prettier run

* Remove false point from user profiles docs

* Add comment about profile versioning to types

* Fix default profile links

* Remove remaining lowercase

* Fix sniper showing XP as XP

* Add game versions to the unlockable map

* Update getMasteryForUnlockable call in planning

* Fix missing locations when updating profiles

* Update versions to v7

* Fix ICA Facility destination mastery

* Fix sniper challenge unlockables showing in inventory

* Sniper Scoring (#273)

* Initial sniper scoring

* Fix linting errors

* Update require table

* Calculate and display final sniper score on end screen

* Bump SMP version to v5.7.0

* Update since version for scoring

* Fix create inventory call for sniper scoring

* Support sniper unlockables in the inventory

* Update versions to v7

* Reflect changes to createInventory in scoreHandler

* Get unlockable name in completion data

* It was not okay.

* Thanks webstorm

* Add support for /profiles/page/GetMasteryCompletionDataForUnlockable

* Support sniper play next

* Remove sniper gamemodes template from overrides

* Remove debug prints from scoring event handler

* Fix challenge multiplier

* Exclude sniper unlockables from stashpoint

* Start fixing up the missionEnd response for sniper

* Update misleading comment

* Use existing global challenge to check for SA on sniper contracts

* Re-add removed global challenges

* Proper support for the mission end screen on sniper contracts

* Remove redundant label

---------

Signed-off-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com>
Co-authored-by: Govert de Gans <grappigegovert@hotmail.com>

* Add co-op sniper scoring defs

* Update MasteryUnlockable template

* Bump SMP version to v5.9.3

* Re-add deepmerge

* Fix SMP checksum

* Fix linting errors caused by merge

* Fix score handler imports

* Move load flags

* Remove unnecessary game version arg

* Whoopsies

Co-authored-by: Reece Dunham <me@rdil.rocks>
Signed-off-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com>

---------

Signed-off-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com>
Co-authored-by: Govert de Gans <grappigegovert@hotmail.com>
Co-authored-by: Reece Dunham <me@rdil.rocks>
2023-07-24 23:47:28 +01:00

1202 lines
40 KiB
TypeScript

/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2023 The Peacock Project Team
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Response } from "express"
import {
contractTypes,
DEFAULT_MASTERY_MAXLEVEL,
difficultyToString,
EVERGREEN_LEVEL_INFO,
evergreenLevelForXp,
handleAxiosError,
isObjectiveActive,
levelForXp,
PEACOCKVERSTRING,
SNIPER_LEVEL_INFO,
sniperLevelForXp,
xpRequiredForLevel,
} from "./utils"
import { contractSessions, getCurrentState } from "./eventHandler"
import { getConfig } from "./configSwizzleManager"
import { _theLastYardbirdScpc, controller } from "./controller"
import type {
ContractHistory,
ContractSession,
GameChanger,
GameVersion,
MissionManifest,
MissionManifestObjective,
RequestWithJwt,
Seconds,
} from "./types/types"
import {
escalationTypes,
getLevelCount,
} from "./contracts/escalations/escalationService"
import { getUserData, writeUserData } from "./databaseHandler"
import axios from "axios"
import { getFlag } from "./flags"
import { log, LogLevel } from "./loggingInterop"
import {
generateCompletionData,
getSubLocationByName,
} from "./contracts/dataGen"
import { liveSplitManager } from "./livesplit/liveSplitManager"
import { ScoringHeadline } from "./types/scoring"
import { MissionEndRequestQuery } from "./types/gameSchemas"
import { ChallengeFilterType } from "./candle/challengeHelpers"
import { getCompletionPercent } from "./menus/destinations"
import {
CalculateScoreResult,
CalculateSniperScoreResult,
CalculateXpResult,
MissionEndChallenge,
MissionEndDrop,
MissionEndEvergreen,
MissionEndResponse,
} from "./types/score"
import { MasteryData } from "./types/mastery"
import { createInventory, InventoryItem, getUnlockablesById } from "./inventory"
import { calculatePlaystyle } from "./playStyles"
export function calculateGlobalXp(
contractSession: ContractSession,
gameVersion: GameVersion,
): CalculateXpResult {
const completedChallenges: MissionEndChallenge[] = []
let totalXp = 0
// TODO: Merge with the non-global challenges?
for (const challengeId of Object.keys(contractSession.challengeContexts)) {
const data = contractSession.challengeContexts[challengeId]
if (data.timesCompleted <= 0) {
continue
}
const challenge = controller.challengeService.getChallengeById(
challengeId,
gameVersion,
)
if (!challenge || !challenge.Xp || !challenge.Tags.includes("global")) {
continue
}
const challengeXp = challenge.Xp * data.timesCompleted
totalXp += challengeXp
const challengeData = {
ChallengeId: challenge.Id,
ChallengeTags: challenge.Tags,
ChallengeName: challenge.Name,
ChallengeImageUrl: challenge.ImageName,
ChallengeDescription: challenge.Description,
// TODO: We probably have to use Repeatable here somehow to determine when to "repeat" a challenge.
XPGain: challengeXp,
IsGlobal: true,
IsActionReward: challenge.Tags.includes("actionreward"),
Drops: challenge.Drops,
}
completedChallenges.push(challengeData)
}
return {
completedChallenges: completedChallenges,
xp: totalXp,
}
}
export function calculateScore(
gameVersion: GameVersion,
contractSession: ContractSession,
contractData: MissionManifest,
timeTotal: Seconds,
): CalculateScoreResult {
// Bonuses
const bonuses = [
{
headline: "UI_SCORING_SUMMARY_OBJECTIVES",
bonusId: "AllObjectivesCompletedBonus",
condition:
gameVersion === "h1" ||
contractData.Metadata.Id ===
"2d1bada4-aa46-4954-8cf5-684989f1668a" ||
contractData.Data.Objectives?.every(
(obj: MissionManifestObjective) =>
obj.ExcludeFromScoring ||
contractSession.completedObjectives.has(obj.Id) ||
(obj.IgnoreIfInactive &&
!isObjectiveActive(
obj,
contractSession.completedObjectives,
)) ||
"Success" ===
getCurrentState(contractSession.Id, obj.Id),
),
fractionNumerator: 2,
fractionDenominator: 3,
},
{
headline: "UI_SCORING_SUMMARY_NOT_SPOTTED",
bonusId: "Unspotted",
condition: [
...contractSession.witnesses,
...contractSession.spottedBy,
].every(
(witness) =>
(gameVersion === "h1"
? false
: contractSession.targetKills.has(witness)) ||
contractSession.npcKills.has(witness),
),
},
{
headline: "UI_SCORING_SUMMARY_NO_NOTICED_KILLS",
bonusId: "NoWitnessedKillsBonus",
condition: [...contractSession.killsNoticedBy].every(
(witness) =>
(gameVersion === "h1"
? true
: contractSession.targetKills.has(witness)) ||
contractSession.npcKills.has(witness),
),
},
{
headline: "UI_SCORING_SUMMARY_NO_BODIES_FOUND",
bonusId: "NoBodiesFound",
condition:
contractSession.legacyHasBodyBeenFound === false &&
[...contractSession.bodiesFoundBy].every(
(witness) =>
(gameVersion === "h1"
? false
: contractSession.targetKills.has(witness)) ||
contractSession.npcKills.has(witness),
),
},
{
headline: "UI_SCORING_SUMMARY_NO_RECORDINGS",
bonusId: "SecurityErased",
condition:
contractSession.recording === "NOT_SPOTTED" ||
contractSession.recording === "ERASED",
},
]
// Non-target kills
const nonTargetKills =
contractData?.Metadata.AllowNonTargetKills === true
? 0
: contractSession.npcKills.size + contractSession.crowdNpcKills
let totalScore = -5000 * nonTargetKills
// Headlines and bonuses
const scoringHeadlines = []
const awardedBonuses = []
const failedBonuses = []
const headlineObjTemplate: Partial<ScoringHeadline> = {
type: "summary",
count: "",
scoreIsFloatingType: false,
fractionNumerator: 0,
fractionDenominator: 0,
scoreTotal: 20000,
}
for (const bonus of bonuses) {
const bonusObj = {
Score: 20000,
Id: bonus.bonusId,
FractionNumerator: bonus.fractionNumerator || 0,
FractionDenominator: bonus.fractionDenominator || 0,
}
const headlineObj = Object.assign(
{},
headlineObjTemplate,
) as ScoringHeadline
headlineObj.headline = bonus.headline
headlineObj.fractionNumerator = bonus.fractionNumerator || 0
headlineObj.fractionDenominator = bonus.fractionDenominator || 0
if (bonus.condition) {
totalScore += 20000
scoringHeadlines.push(headlineObj)
awardedBonuses.push(bonusObj)
} else {
bonusObj.Score = 0
headlineObj.scoreTotal = 0
scoringHeadlines.push(headlineObj)
failedBonuses.push(bonusObj)
}
}
totalScore = Math.max(0, totalScore)
scoringHeadlines.push(
Object.assign(Object.assign({}, headlineObjTemplate), {
headline: "UI_SCORING_SUMMARY_KILL_PENALTY",
count: nonTargetKills > 0 ? `${nonTargetKills}x-5000` : "",
scoreTotal: -5000 * nonTargetKills,
}) as ScoringHeadline,
)
const timeHours = Math.floor(timeTotal / 3600)
const timeMinutes = Math.floor((timeTotal - timeHours * 3600) / 60)
const timeSeconds = Math.floor(
timeTotal - timeHours * 3600 - timeMinutes * 60,
)
let timebonus = 0
// formula from https://hitmanforumarchive.notex.app/#/t/how-the-time-bonus-is-calculated/17438 (https://archive.ph/pRjzI)
const scorePoints = [
[0, 1.1], // 1.1 bonus multiplier at 0 secs (0 min)
[300, 0.7], // 0.7 bonus multiplier at 300 secs (5 min)
[900, 0.6], // 0.6 bonus multiplier at 900 secs (15 min)
[17100, 0.0], // 0 bonus multiplier at 17100 secs (285 min)
]
let prevsecs: number, prevmultiplier: number
for (const [secs, multiplier] of scorePoints) {
if (timeTotal > secs) {
prevsecs = secs
prevmultiplier = multiplier
continue
}
// linear interpolation between current and previous scorePoints
const bonusMultiplier =
prevmultiplier! -
((prevmultiplier! - multiplier) * (timeTotal - prevsecs!)) /
(secs - prevsecs!)
timebonus = totalScore * bonusMultiplier
break
}
timebonus = Math.round(timebonus)
const totalScoreWithBonus = totalScore + timebonus
awardedBonuses.push({
Score: timebonus,
Id: "SwiftExecution",
FractionNumerator: 0,
FractionDenominator: 0,
})
scoringHeadlines.push(
Object.assign(Object.assign({}, headlineObjTemplate), {
headline: "UI_SCORING_SUMMARY_TIME",
count: `${`0${timeHours}`.slice(-2)}:${`0${timeMinutes}`.slice(
-2,
)}:${`0${timeSeconds}`.slice(-2)}`,
scoreTotal: timebonus,
}) as ScoringHeadline,
)
for (const type of ["total", "subtotal"]) {
scoringHeadlines.push(
Object.assign(Object.assign({}, headlineObjTemplate), {
type,
headline: `UI_SCORING_SUMMARY_${type.toUpperCase()}`,
scoreTotal: totalScoreWithBonus,
}) as ScoringHeadline,
)
}
// Stars
let stars =
5 -
[...bonuses, { condition: nonTargetKills === 0 }].filter(
(x) => !x!.condition,
).length // one star less for each bonus missed
stars = stars < 0 ? 0 : stars // clamp to 0
// Achieved masteries
const achievedMasteries = [
{
score: -5000 * nonTargetKills,
RatioParts: nonTargetKills,
RatioTotal: nonTargetKills,
Id: "KillPenaltyMastery",
BaseScore: -5000,
},
]
// NOTE: need to have all bonuses except objectives for SA
const silentAssassin = [
...bonuses.slice(1),
{ condition: nonTargetKills === 0 },
].every((x) => x.condition)
return {
stars: stars,
scoringHeadlines: scoringHeadlines,
achievedMasteries: achievedMasteries,
awardedBonuses: awardedBonuses,
failedBonuses: failedBonuses,
silentAssassin: silentAssassin,
score: totalScore,
scoreWithBonus: totalScoreWithBonus,
}
}
export function calculateSniperScore(
contractSession: ContractSession,
timeTotal: Seconds,
inventory: InventoryItem[],
): [CalculateSniperScoreResult, ScoringHeadline[]] {
const timeMinutes = Math.floor(timeTotal / 60)
const timeSeconds = Math.floor(timeTotal % 60)
const timeMiliseconds = Math.floor(((timeTotal % 60) - timeSeconds) * 1000)
const bonusTimeStart =
contractSession.firstKillTimestamp ?? contractSession.timerStart
const bonusTimeEnd = contractSession.timerEnd
const bonusTimeTotal: Seconds =
(bonusTimeEnd as number) - (bonusTimeStart as number)
let timeBonus = 0
// TODO? generate this curve from contractSession.scoring.Settings["timebonus"] somehow
const scorePoints = [
[0, 50000], // 50000 bonus score at 0 secs (0 min)
[240, 40000], // 40000 bonus score at 240 secs (4 min)
[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
for (const [secs, score] of scorePoints) {
if (bonusTimeTotal > secs) {
prevsecs = secs
prevscore = score
continue
}
// linear interpolation between current and previous scorePoints
timeBonus =
prevscore -
((prevscore - score) * (bonusTimeTotal - prevsecs)) /
(secs - prevsecs)
break
}
timeBonus = Math.floor(timeBonus)
const defaultHeadline: Partial<ScoringHeadline> = {
type: "summary",
count: "",
scoreIsFloatingType: false,
fractionNumerator: 0,
fractionDenominator: 0,
scoreTotal: 0,
}
const baseScore = contractSession.scoring.Context["TotalScore"]
const challengeMultiplier = contractSession.scoring.Settings["challenges"][
"Unlockables"
].reduce((acc, unlockable) => {
const item = inventory.find((item) => item.Unlockable.Id === unlockable)
if (item) {
return acc + item.Unlockable.Properties["Multiplier"]
}
return acc
}, 1.0)
const bulletsMissed = 0 // TODO? not sure if neccessary, the penalty is always 0 for inbuilt contracts
const bulletsMissedPenalty =
bulletsMissed *
contractSession.scoring.Settings["bulletsused"]["penalty"]
// Get SA status from global SA challenge for contracttype sniper
const silentAssassin =
contractSession.challengeContexts[
"029c4971-0ddd-47ab-a568-17b007eec04e"
].state !== "Failure"
const saBonus = silentAssassin
? contractSession.scoring.Settings["silentassassin"]["score"]
: 0
const saMultiplier = silentAssassin
? contractSession.scoring.Settings["silentassassin"]["multiplier"]
: 1.0
const subTotalScore = baseScore + timeBonus + saBonus - bulletsMissedPenalty
const totalScore = Math.round(
subTotalScore * challengeMultiplier * saMultiplier,
)
const headlines = [
{
headline: "UI_SNIPERSCORING_SUMMARY_BASESCORE",
scoreTotal: baseScore,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_BULLETS_MISSED_PENALTY",
scoreTotal: bulletsMissedPenalty,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_TIME_BONUS",
count: `${String(timeMinutes).padStart(2, "0")}:${String(
timeSeconds,
).padStart(2, "0")}.${String(timeMiliseconds).padStart(3, "0")}`,
scoreTotal: timeBonus,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_SILENT_ASSASIN_BONUS",
scoreTotal: saBonus,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_SUBTOTAL",
scoreTotal: subTotalScore,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_CHALLENGE_MULTIPLIER",
scoreIsFloatingType: true,
scoreTotal: challengeMultiplier,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_SILENT_ASSASIN_MULTIPLIER",
scoreIsFloatingType: true,
scoreTotal: saMultiplier,
},
{
type: "total",
headline: "UI_SNIPERSCORING_SUMMARY_TOTAL",
scoreTotal: totalScore,
},
].map((e) => {
return Object.assign(
Object.assign({}, defaultHeadline),
e,
) as ScoringHeadline
})
return [
{
FinalScore: totalScore,
BaseScore: baseScore,
TotalChallengeMultiplier: challengeMultiplier,
BulletsMissed: bulletsMissed,
BulletsMissedPenalty: bulletsMissedPenalty,
TimeTaken: timeTotal,
TimeBonus: timeBonus,
SilentAssassin: silentAssassin,
SilentAssassinBonus: saBonus,
SilentAssassinMultiplier: saMultiplier,
},
headlines,
]
}
export async function missionEnd(
req: RequestWithJwt<MissionEndRequestQuery>,
res: Response,
): Promise<void> {
// TODO: For this entire function, add support for 2016 difficulties
// Resolve the contract session
if (!req.query.contractSessionId) {
res.status(400).end()
return
}
const sessionDetails = contractSessions.get(req.query.contractSessionId)
if (!sessionDetails) {
res.status(404).send("contract session not found")
return
}
if (sessionDetails.userId !== req.jwt.unique_name) {
res.status(401).send("requested score for other user's session")
return
}
// Resolve userdata
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
// Resolve contract data
const contractData =
req.gameVersion === "scpc" &&
sessionDetails.contractId === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(sessionDetails.contractId, true)
if (!contractData) {
res.status(404).send("contract not found")
return
}
// Handle escalation groups
if (escalationTypes.includes(contractData.Metadata.Type)) {
const eGroupId =
contractData.Metadata.InGroup ?? contractData.Metadata.Id
if (!eGroupId) {
log(
LogLevel.ERROR,
`Unregistered escalation group ${sessionDetails.contractId}`,
)
res.status(500).end()
return
}
if (!userData.Extensions.PeacockEscalations[eGroupId]) {
userData.Extensions.PeacockEscalations[eGroupId] = 1
}
const history: ContractHistory = {
LastPlayedAt: new Date().getTime(),
IsEscalation: true,
}
if (
userData.Extensions.PeacockEscalations[eGroupId] ===
getLevelCount(controller.resolveContract(eGroupId))
) {
// we are on the final level, and the user completed this level
if (
!userData.Extensions.PeacockCompletedEscalations?.includes(
eGroupId,
)
) {
// the user never finished this escalation before
userData.Extensions.PeacockCompletedEscalations.push(eGroupId)
}
history.Completed = true
} else {
// not the final level
userData.Extensions.PeacockEscalations[eGroupId] += 1
}
if (!userData.Extensions.PeacockPlayedContracts[eGroupId]) {
userData.Extensions.PeacockPlayedContracts[eGroupId] = {}
}
userData.Extensions.PeacockPlayedContracts[eGroupId] = history
writeUserData(req.jwt.unique_name, req.gameVersion)
} else if (contractTypes.includes(contractData.Metadata.Type)) {
// Update the contract in the played list
const id = contractData.Metadata.Id
if (!userData.Extensions.PeacockPlayedContracts[id]) {
userData.Extensions.PeacockPlayedContracts[id] = {}
}
userData.Extensions.PeacockPlayedContracts[id] = {
LastPlayedAt: new Date().getTime(),
Completed: true,
}
writeUserData(req.jwt.unique_name, req.gameVersion)
}
const levelData = controller.resolveContract(
sessionDetails.contractId,
false,
)
// Resolve the id of the parent location
const subLocation = getSubLocationByName(
levelData.Metadata.Location,
req.gameVersion,
)
const locationParentId = subLocation
? subLocation.Properties?.ParentLocation
: levelData.Metadata.Location
if (!locationParentId) {
res.status(404).send("location parentid not found")
return
}
// Resolve all opportunities for the location
const opportunities = contractData.Metadata.Opportunities
const opportunityCount = opportunities ? opportunities.length : 0
const opportunityCompleted = opportunities
? opportunities.filter(
(ms) => ms in userData.Extensions.opportunityprogression,
).length
: 0
// Resolve all challenges for the location
const locationChallenges =
controller.challengeService.getGroupedChallengeLists(
{
type: ChallengeFilterType.ParentLocation,
parent: locationParentId,
},
locationParentId,
req.gameVersion,
)
const contractChallenges =
controller.challengeService.getChallengesForContract(
sessionDetails.contractId,
req.gameVersion,
req.jwt.unique_name,
sessionDetails.difficulty,
)
const locationChallengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
locationChallenges,
userData.Id,
req.gameVersion,
)
const contractChallengeCompletion =
controller.challengeService.countTotalNCompletedChallenges(
contractChallenges,
userData.Id,
req.gameVersion,
)
const locationPercentageComplete = getCompletionPercent(
locationChallengeCompletion.CompletedChallengesCount,
locationChallengeCompletion.ChallengesCount,
opportunityCompleted,
opportunityCount,
)
const playerProgressionData =
userData.Extensions.progression.PlayerProfileXP
// Calculate XP based on global challenges.
const calculateXpResult: CalculateXpResult = calculateGlobalXp(
sessionDetails,
req.gameVersion,
)
let justTickedChallenges = 0
let totalXpGain = calculateXpResult.xp
// Calculate XP based on non-global challenges. Remember to add elusive challenges of the contract
Object.values({
...locationChallenges,
...(Object.keys(contractChallenges).includes("elusive") && {
elusive: contractChallenges.elusive,
}),
})
.flat()
.filter((challengeData) => {
return (
!challengeData.Tags.includes("global") &&
controller.challengeService.fastGetIsUnticked(
userData,
challengeData.Id,
)
)
})
.forEach((challengeData) => {
const userId = req.jwt.unique_name
const gameVersion = req.gameVersion
userData.Extensions.ChallengeProgression[challengeData.Id].Ticked =
true
writeUserData(userId, gameVersion)
justTickedChallenges++
totalXpGain += challengeData.Rewards.MasteryXP
calculateXpResult.completedChallenges.push({
ChallengeId: challengeData.Id,
ChallengeTags: challengeData.Tags,
ChallengeName: challengeData.Name,
ChallengeImageUrl: challengeData.ImageName,
ChallengeDescription: challengeData.Description,
XPGain: challengeData.Rewards.MasteryXP,
IsGlobal: false,
IsActionReward: challengeData.Tags.includes("actionreward"),
Drops: challengeData.Drops,
})
})
let completionData = generateCompletionData(
levelData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
contractData.Metadata.Type,
req.query.masteryUnlockableId,
)
// Calculate the old location progression based on the current one and process it
const oldLocationXp = completionData.PreviouslySeenXp
? completionData.PreviouslySeenXp
: completionData.XP - totalXpGain
let oldLocationLevel = levelForXp(oldLocationXp)
const newLocationXp = completionData.XP
let newLocationLevel = levelForXp(newLocationXp)
if (!req.query.masteryUnlockableId) {
userData.Extensions.progression.Locations[
locationParentId
].PreviouslySeenXp = newLocationXp
}
writeUserData(req.jwt.unique_name, req.gameVersion)
const masteryData = controller.masteryService.getMasteryPackage(
locationParentId,
req.gameVersion,
)
let maxLevel = 1
let locationLevelInfo = [0]
if (masteryData) {
maxLevel =
(req.query.masteryUnlockableId
? masteryData.SubPackages.find(
(subPkg) => subPkg.Id === req.query.masteryUnlockableId,
).MaxLevel
: masteryData.MaxLevel) || DEFAULT_MASTERY_MAXLEVEL
locationLevelInfo = Array.from({ length: maxLevel }, (_, i) => {
return xpRequiredForLevel(i + 1)
})
}
// Calculate the old playerprofile progression based on the current one and process it
const oldPlayerProfileXp = playerProgressionData.Total - totalXpGain
const oldPlayerProfileLevel = levelForXp(oldPlayerProfileXp)
const newPlayerProfileXp = playerProgressionData.Total
const newPlayerProfileLevel = levelForXp(newPlayerProfileXp)
// NOTE: We assume the ProfileLevel is currently already up-to-date
const profileLevelInfo = []
for (
let level = oldPlayerProfileLevel;
level <= newPlayerProfileLevel + 1;
level++
) {
profileLevelInfo.push(xpRequiredForLevel(level))
}
const profileLevelInfoOffset = oldPlayerProfileLevel - 1
// Time
const timeTotal: Seconds =
(sessionDetails.timerEnd as number) -
(sessionDetails.timerStart as number)
// Playstyle
const calculatedPlaystyles = calculatePlaystyle(sessionDetails)
let playstyle =
calculatedPlaystyles[0].Score !== 0
? calculatedPlaystyles[0]
: undefined
// Calculate score and summary
const calculateScoreResult = calculateScore(
req.gameVersion,
sessionDetails,
contractData,
timeTotal,
)
// Evergreen
const evergreenData: MissionEndEvergreen = <MissionEndEvergreen>{
PayoutsCompleted: [],
PayoutsFailed: [],
}
if (contractData.Metadata.Type === "evergreen") {
const gameChangerProperties = getConfig<Record<string, GameChanger>>(
"EvergreenGameChangerProperties",
true,
)
let totalPayout = 0
// ASSUMPTION: All payout objectives have a "condition"-category objective
// and a "secondary"-category objective with a "MyPayout" in the context.
Object.keys(gameChangerProperties).forEach((e) => {
const gameChanger = gameChangerProperties[e]
const conditionObjective = gameChanger.Objectives.find(
(e) => e.Category === "condition",
)
const secondaryObjective = gameChanger.Objectives.find(
(e) =>
e.Category === "secondary" &&
e.Definition.Context["MyPayout"],
)
if (
conditionObjective &&
secondaryObjective &&
sessionDetails.objectiveStates.get(conditionObjective.Id) ===
"Success"
) {
const payoutObjective = {
Name: gameChanger.Name,
Payout: parseInt(
sessionDetails.objectiveContexts.get(
secondaryObjective.Id,
)["MyPayout"] || 0,
),
IsPrestige: gameChanger.IsPrestigeObjective || false,
}
if (
!sessionDetails.evergreen.failed &&
sessionDetails.objectiveStates.get(
secondaryObjective.Id,
) === "Success"
) {
totalPayout += payoutObjective.Payout
evergreenData.PayoutsCompleted.push(payoutObjective)
} else {
evergreenData.PayoutsFailed.push(payoutObjective)
}
}
})
evergreenData.Payout = totalPayout
evergreenData.EndStateEventName =
sessionDetails.evergreen.scoringScreenEndState
locationLevelInfo = EVERGREEN_LEVEL_INFO
// Override the location levels to trigger potential drops
oldLocationLevel = evergreenLevelForXp(oldLocationXp)
newLocationLevel = completionData.Level
// Override the silent assassin rank
if (calculateScoreResult.silentAssassin) {
playstyle = {
Id: "595f6ff1-85bf-4e4f-a9ee-76038a455648",
Name: "UI_PLAYSTYLE_ICA_STEALTH_ASSASSIN",
Type: "STEALTH_ASSASSIN",
Score: 0,
}
}
calculateScoreResult.silentAssassin = false
// Overide the calculated score
calculateScoreResult.stars = undefined
}
// Sniper
let unlockableProgression = undefined
let sniperChallengeScore = undefined
let contractScore = {
Total: calculateScoreResult.scoreWithBonus,
AchievedMasteries: calculateScoreResult.achievedMasteries,
AwardedBonuses: calculateScoreResult.awardedBonuses,
TotalNoMultipliers: calculateScoreResult.score,
TimeUsedSecs: timeTotal,
StarCount: calculateScoreResult.stars,
FailedBonuses: calculateScoreResult.failedBonuses,
SilentAssassin: calculateScoreResult.silentAssassin,
}
if (contractData.Metadata.Type === "sniper") {
const userInventory = createInventory(
req.jwt.unique_name,
req.gameVersion,
undefined,
)
const [sniperScore, headlines] = calculateSniperScore(
sessionDetails,
timeTotal,
userInventory,
)
sniperChallengeScore = sniperScore
// Grant sniper mastery
controller.progressionService.grantProfileProgression(
0,
sniperScore.FinalScore,
[],
sessionDetails,
userData,
locationParentId,
req.query.masteryUnlockableId,
)
// Update completion data with latest mastery
locationLevelInfo = SNIPER_LEVEL_INFO
oldLocationLevel = sniperLevelForXp(oldLocationXp)
// Temporarily get completion data for the unlockable
completionData = generateCompletionData(
levelData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
"sniper", // We know the type will be sniper.
req.query.masteryUnlockableId,
)
newLocationLevel = completionData.Level
unlockableProgression = {
Id: completionData.Id,
Level: completionData.Level,
LevelInfo: locationLevelInfo,
Name: completionData.Name,
XP: completionData.XP,
XPGain:
completionData.Level === completionData.MaxLevel
? 0
: sniperScore.FinalScore,
}
userData.Extensions.progression.Locations[locationParentId][
req.query.masteryUnlockableId
].PreviouslySeenXp = completionData.XP
writeUserData(req.jwt.unique_name, req.gameVersion)
// Set the completion data to the location so the end screen formats properly.
completionData = generateCompletionData(
levelData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
)
// Override the contract score
contractScore = undefined
// Override the playstyle
playstyle = undefined
calculateScoreResult.stars = undefined
calculateScoreResult.scoringHeadlines = headlines
}
// Mastery Drops
let masteryDrops: MissionEndDrop[] = []
if (newLocationLevel - oldLocationLevel > 0) {
// We get the subpackage as it functions like getMasteryDataForDestination
// but allows us to get the specific unlockable if required.
const masteryData =
controller.masteryService.getMasteryDataForSubPackage(
locationParentId,
req.query.masteryUnlockableId ?? undefined,
req.gameVersion,
req.jwt.unique_name,
) as MasteryData
if (masteryData) {
masteryDrops = masteryData.Drops.filter(
(e) =>
e.Level > oldLocationLevel && e.Level <= newLocationLevel,
).map((e) => {
return {
Unlockable: e.Unlockable,
}
})
}
}
// Challenge Drops
const challengeDrops: MissionEndDrop[] =
calculateXpResult.completedChallenges.reduce((acc, challenge) => {
if (challenge?.Drops?.length) {
const drops = getUnlockablesById(
challenge.Drops,
req.gameVersion,
)
delete challenge.Drops
for (const drop of drops) {
acc.push({
Unlockable: drop,
SourceChallenge: challenge,
})
}
}
return acc
}, [])
// Setup the result
const result: MissionEndResponse = {
MissionReward: {
LocationProgression: {
LevelInfo: locationLevelInfo,
XP: completionData.XP,
Level: completionData.Level,
Completion: completionData.Completion,
// NOTE: Official makes this 0 if maximum Mastery is reached
XPGain: completionData.Level === maxLevel ? 0 : totalXpGain,
HideProgression: masteryData?.HideProgression || false,
},
ProfileProgression: {
LevelInfo: profileLevelInfo,
LevelInfoOffset: profileLevelInfoOffset,
XP: newPlayerProfileXp,
Level: newPlayerProfileLevel,
XPGain: totalXpGain,
},
Challenges: calculateXpResult.completedChallenges,
Drops: [...masteryDrops, ...challengeDrops],
// TODO: Do these exist? Appears to be optional.
OpportunityRewards: [],
UnlockableProgression: unlockableProgression,
CompletionData: completionData,
ChallengeCompletion: locationChallengeCompletion,
ContractChallengeCompletion: contractChallengeCompletion,
OpportunityStatistics: {
Count: opportunityCount,
Completed: opportunityCompleted,
},
LocationCompletionPercent: locationPercentageComplete,
},
ScoreOverview: {
XP: completionData.XP,
Level: completionData.Level,
Completion: completionData.Completion,
// NOTE: Official appears to always make this 0
XPGain: 0,
ChallengesCompleted: justTickedChallenges,
LocationHideProgression: masteryData?.HideProgression || false,
ProdileId1: req.jwt.unique_name,
stars: calculateScoreResult.stars,
ScoreDetails: {
Headlines: calculateScoreResult.scoringHeadlines,
},
ContractScore: contractScore,
SniperChallengeScore: sniperChallengeScore,
SilentAssassin:
contractScore?.SilentAssassin ||
sniperChallengeScore?.silentAssassin ||
false,
// TODO: Use data from the leaderboard?
NewRank: 1,
RankCount: 1,
Rank: 1,
FriendsRankCount: 1,
FriendsRank: 1,
IsPartOfTopScores: false,
PlayStyle: playstyle,
IsNewBestScore: false,
IsNewBestTime: false,
IsNewBestStars: false,
Evergreen: evergreenData,
},
}
// Finalize the response
if ((getFlag("autoSplitterForceSilentAssassin") as boolean) === true) {
if (result.ScoreOverview.SilentAssassin) {
await liveSplitManager.completeMission(timeTotal)
} else {
await liveSplitManager.failMission(timeTotal)
}
} else {
await liveSplitManager.completeMission(timeTotal)
}
if (
getFlag("leaderboards") === true &&
sessionDetails.compat === true &&
contractData.Metadata.Type !== "vsrace" &&
contractData.Metadata.Type !== "evergreen" &&
// Disable sending sniper scores for now
contractData.Metadata.Type !== "sniper"
) {
try {
// update leaderboards
await axios.post(
`${getFlag("leaderboardsHost")}/leaderboards/commit`,
{
contractId: sessionDetails.contractId,
gameDifficulty: difficultyToString(
sessionDetails.difficulty,
),
gameVersion: req.gameVersion,
platform: req.jwt.platform,
username: userData.Gamertag,
platformId:
req.jwt.platform === "epic"
? userData.EpicId
: userData.SteamId,
score: calculateScoreResult.scoreWithBonus,
data: {
Score: {
Total: calculateScoreResult.scoreWithBonus,
AchievedMasteries:
result.ScoreOverview.ContractScore
.AchievedMasteries,
AwardedBonuses:
result.ScoreOverview.ContractScore
.AwardedBonuses,
TotalNoMultipliers:
result.ScoreOverview.ContractScore
.TotalNoMultipliers,
TimeUsedSecs:
result.ScoreOverview.ContractScore.TimeUsedSecs,
FailedBonuses: null,
IsVR: false,
SilentAssassin: result.ScoreOverview.SilentAssassin,
StarCount: calculateScoreResult.stars,
},
GroupIndex: 0,
SniperChallengeScore: sniperChallengeScore,
PlayStyle: result.ScoreOverview.PlayStyle || null,
Description: "UI_MENU_SCORE_CONTRACT_COMPLETED",
ContractSessionId: req.query.contractSessionId,
Percentile: {
Spread: Array(10).fill(0),
Index: 0,
},
peacockHeadlines:
result.ScoreOverview.ScoreDetails.Headlines,
},
},
{
headers: {
"Peacock-Version": PEACOCKVERSTRING,
},
},
)
} catch (e) {
handleAxiosError(e)
log(
LogLevel.WARN,
"Failed to commit leaderboards data! Either you or the server may be offline.",
)
}
}
res.json({
template:
req.gameVersion === "scpc"
? getConfig("FrankensteinScoreOverviewTemplate", false)
: null,
data: result,
})
}