mirror of
https://github.com/thepeacockproject/Peacock
synced 2024-11-29 09:15:11 +01:00
14faf6ed25
* 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>
1193 lines
39 KiB
TypeScript
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,
|
|
})
|
|
}
|