1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-11-29 09:15:11 +01:00
Peacock/components/scoreHandler.ts
moonysolari 14faf6ed25
Add global escalation challenges (#210)
* Added escalation challenges

* add legacy and custom escalations to list

* add h2 escalation challenges

* Run prettier

* Added H1 Escalation challenges

---------

Co-authored-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com>
2023-04-14 23:41:43 +01:00

1193 lines
39 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 {
DEFAULT_MASTERY_MAXLEVEL,
contractTypes,
difficultyToString,
evergreenLevelForXp,
EVERGREEN_LEVEL_INFO,
handleAxiosError,
isObjectiveActive,
levelForXp,
PEACOCKVERSTRING,
SNIPER_LEVEL_INFO,
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,
RatingKill,
RequestWithJwt,
Seconds,
} from "./types/types"
import {
contractIdToEscalationGroupId,
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 { Playstyle, ScoringHeadline } from "./types/scoring"
import { MissionEndRequestQuery } from "./types/gameSchemas"
import { ChallengeFilterType } from "./candle/challengeHelpers"
import { getCompletionPercent } from "./menus/destinations"
import {
CalculateXpResult,
CalculateScoreResult,
MissionEndResponse,
MissionEndDrop,
MissionEndEvergreen,
MissionEndChallenge,
} from "./types/score"
import { MasteryData } from "./types/mastery"
import { getDataForUnlockables } from "./inventory"
/**
* Checks the criteria of each possible play-style, ranking them by scoring.
*
* @author CurryMaker
* @param session The contract session.
* @returns The play-styles, ranked from best fit to worst fit.
*/
//TODO: This could use an update with more playstyles
export function calculatePlaystyle(
session: Partial<{ kills: Set<RatingKill> }>,
): Playstyle[] {
const playstylesCopy = getConfig("Playstyles", true) as Playstyle[]
// Resetting the scores...
playstylesCopy.forEach((p) => {
p.Score = 0
})
const doneWeaponTypes: string[] = []
const doneKillMethods: string[] = []
const doneAccidents: string[] = []
session.kills.forEach((k) => {
if (k.KillClass === "ballistic") {
if (k.KillItemCategory === "pistol") {
playstylesCopy[1].Score += 6000
}
if (k.IsHeadshot) {
playstylesCopy[0].Score += 6000
} else {
playstylesCopy[0].Score -= 2000
}
if (doneWeaponTypes.includes(k.KillItemCategory)) {
playstylesCopy[2].Score -= 2000
} else {
playstylesCopy[2].Score += 6000
doneWeaponTypes.push(k.KillItemCategory)
}
if (k.KillItemCategory === "shotgun") {
playstylesCopy[7].Score += 6000
}
if (k.KillItemCategory === "assaultrifle") {
playstylesCopy[9].Score += 6000
}
if (k.KillItemCategory === "sniperrifle") {
playstylesCopy[10].Score += 6000
}
if (k.KillItemCategory === "smg") {
playstylesCopy[15].Score += 6000
}
} else if (k.KillClass === "melee") {
if (
k.KillMethodBroad === "accident" &&
k.KillItemCategory === undefined
) {
playstylesCopy[4].Score += 6000
}
if (k.KillMethodStrict === "fiberwire") {
playstylesCopy[13].Score += 6000
}
if (k.KillMethodBroad === "unarmed") {
playstylesCopy[16].Score += 6000
}
if (k.KillMethodStrict === "accident_drown") {
playstylesCopy[6].Score += 6000
}
if (k.KillMethodBroad === "accident") {
if (doneAccidents.includes(k.KillMethodStrict)) {
playstylesCopy[8].Score -= 2000
} else {
playstylesCopy[8].Score += 6000
doneAccidents.push(k.KillMethodStrict)
}
}
playstylesCopy[5].Score += 6000
} else if (k.KillClass === "explosion") {
if (k.KillMethodBroad === "explosive") {
playstylesCopy[12].Score += 6000
}
if (k.KillMethodBroad === "accident") {
playstylesCopy[19].Score += 6000
}
} else if (k.KillClass === "unknown") {
if (k.KillMethodStrict === "accident_electric") {
playstylesCopy[11].Score += 6000
}
if (k.KillMethodStrict === "accident_suspended_object") {
playstylesCopy[14].Score += 6000
}
if (k.KillMethodStrict === "accident_burn") {
playstylesCopy[18].Score += 6000
}
if (doneAccidents.includes(k.KillMethodStrict)) {
playstylesCopy[8].Score -= 2000
} else {
playstylesCopy[8].Score += 6000
doneAccidents.push(k.KillMethodStrict)
}
} else if (k.KillClass === "poison") {
playstylesCopy[17].Score += 6000
}
if (doneKillMethods.includes(k.KillClass)) {
playstylesCopy[3].Score -= 2000
} else {
playstylesCopy[3].Score += 6000
doneKillMethods.push(k.KillClass)
}
})
playstylesCopy.sort((a, b) => {
if (a.Score > b.Score) {
return -1
}
return b.Score > a.Score ? 1 : 0
})
return playstylesCopy
}
export function calculateXp(
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,
contractSessionId: string,
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(contractSessionId, 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,
)
//#region Time
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,
)
//#endregion
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 async function missionEnd(
req: RequestWithJwt<MissionEndRequestQuery>,
res: Response,
): Promise<void> {
//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 (contractData.Metadata.Type === "escalation") {
const eGroupId = contractIdToEscalationGroupId(
sessionDetails.contractId,
)
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)
}
//Resolve the id of the parent location
const subLocation = getSubLocationByName(
contractData.Metadata.Location,
req.gameVersion,
)
const locationParentId = subLocation
? subLocation.Properties?.ParentLocation
: contractData.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,
},
locationParentId,
req.gameVersion,
)
const contractChallenges =
controller.challengeService.getChallengesForContract(
sessionDetails.contractId,
req.gameVersion,
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 all challenges, including the global ones.
const calculateXpResult: CalculateXpResult = calculateXp(
sessionDetails,
req.gameVersion,
)
let justTickedChallenges = 0
let masteryXpGain = 0
Object.values(contractChallenges)
.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++
masteryXpGain += 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,
})
})
//NOTE: Official doesn't seem to make up it's mind whether or not XPGain is the same for both Mastery and Profile...
const totalXpGain = calculateXpResult.xp + masteryXpGain
const completionData = generateCompletionData(
contractData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
contractData.Metadata.Type,
)
//Calculate the old location progression based on the current one and process it
const oldLocationXp = completionData.XP - masteryXpGain
let oldLocationLevel = levelForXp(oldLocationXp)
const newLocationXp = completionData.XP
let newLocationLevel = levelForXp(newLocationXp)
const masteryData =
controller.masteryService.getMasteryPackage(locationParentId)
let maxLevel = 1
let locationLevelInfo = [0]
if (masteryData) {
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,
req.query.contractSessionId,
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(completionData.XP - totalXpGain)
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,
}
//TODO: Calculate proper Sniper XP and Score
//TODO: Move most of this to its own calculateSniperScore function
if (contractData.Metadata.Type === "sniper") {
const sniperLoadouts = getConfig("SniperLoadouts", true)
const mainUnlockableProperties =
sniperLoadouts[contractData.Metadata.Location][
req.query.masteryUnlockableId
].MainUnlockable.Properties
unlockableProgression = {
LevelInfo: SNIPER_LEVEL_INFO,
XP: SNIPER_LEVEL_INFO[SNIPER_LEVEL_INFO.length - 1],
Level: SNIPER_LEVEL_INFO.length,
XPGain: 0,
Id: mainUnlockableProperties.ProgressionKey,
Name: mainUnlockableProperties.Name,
}
sniperChallengeScore = {
FinalScore: 112000,
BaseScore: 112000,
TotalChallengeMultiplier: 1.0,
BulletsMissed: 0,
BulletsMissedPenalty: 0,
TimeTaken: timeTotal,
TimeBonus: 0,
SilentAssassin: false,
SilentAssassinBonus: 0,
SilentAssassinMultiplier: 1.0,
}
//Override the contract score
contractScore = undefined
//Override the playstyle
playstyle = undefined
//Override the calculated score
const timeMinutes = Math.floor(timeTotal / 60)
const timeSeconds = Math.floor(timeTotal % 60)
const timeMiliseconds = Math.floor(
((timeTotal % 60) - timeSeconds) * 1000,
)
const defaultHeadline: Partial<ScoringHeadline> = {
type: "summary",
count: "",
scoreIsFloatingType: false,
fractionNumerator: 0,
fractionDenominator: 0,
scoreTotal: 0,
}
const headlines = [
{
headline: "UI_SNIPERSCORING_SUMMARY_BASESCORE",
scoreTotal: 112000,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_BULLETS_MISSED_PENALTY",
scoreTotal: 0,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_TIME_BONUS",
count: `${String(timeMinutes).padStart(2, "0")}:${String(
timeSeconds,
).padStart(2, "0")}.${String(timeMiliseconds).padStart(
3,
"0",
)}`,
scoreTotal: 0,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_SILENT_ASSASIN_BONUS",
scoreTotal: 0,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_SUBTOTAL",
scoreTotal: 112000,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_CHALLENGE_MULTIPLIER",
scoreIsFloatingType: true,
scoreTotal: 1.0,
},
{
headline: "UI_SNIPERSCORING_SUMMARY_SILENT_ASSASIN_MULTIPLIER",
scoreIsFloatingType: true,
scoreTotal: 1.0,
},
{
type: "total",
headline: "UI_SNIPERSCORING_SUMMARY_TOTAL",
scoreTotal: 112000,
},
]
calculateScoreResult.stars = undefined
calculateScoreResult.scoringHeadlines = headlines.map((e) => {
return Object.assign(
Object.assign({}, defaultHeadline),
e,
) as ScoringHeadline
})
}
//Mastery Drops
let masteryDrops: MissionEndDrop[] = []
if (newLocationLevel - oldLocationLevel > 0) {
const masteryData =
controller.masteryService.getMasteryDataForDestination(
locationParentId,
req.gameVersion,
req.jwt.unique_name,
) as MasteryData[]
if (masteryData.length > 0) {
masteryDrops = masteryData[0].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 = getDataForUnlockables(
req.gameVersion,
challenge.Drops,
)
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)
}
//#region Leaderboards
if (
getFlag("leaderboards") === true &&
req.gameVersion !== "scpc" &&
req.gameVersion !== "h1" &&
sessionDetails.compat === true &&
contractData.Metadata.Type !== "vsrace"
) {
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,
// TODO sniper scores
SniperChallengeScore: null,
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.",
)
}
}
//#endregion
res.json({
template:
req.gameVersion === "scpc"
? getConfig("FrankensteinScoreOverviewTemplate", false)
: null,
data: result,
})
}