mirror of
https://github.com/thepeacockproject/Peacock
synced 2024-11-22 22:12:45 +01:00
ce0f64cf30
* feat: Initial support for mastery progression * chore: Fix typo * feat: Award users challenge drops immediately after completion. Also builds the inventory on startup with those drops * feat: Award mastery unlockables to user as soon as they are available * feat: Added flag to toggle mastery progression * fix: Address linting issues * feat: Handle loadout lock for Miami and Hokkaido * fix: Looking for LimitedSlots on parent location, to include all contracts on the location * Update components/flags.ts Co-authored-by: Reece Dunham <me@rdil.rocks> Signed-off-by: J0k3r-1 <128742169+J0k3r-1@users.noreply.github.com> * refactor: Look at location LimitedLoadout to prevent unecessary unlockable lookups * refactor: Add a different Map for mapping Level and Location to an unlockable id * fix: Prevent evergreen gear unlockables to be awarded to the user inventory w/t proper mastery level Evergreen level unlockables for gear are treated the same as packages, and include the actual unlockable item within their properties, so similar logic to packages was needed to address those correctly * refactor: Use gameVersion from contractSession on grantLocationMasteryXp * fix: Fix typo * feat: Add progression service to handle XP and Drop award (#1) * refactor: Cleanup unused imports * refactor: Added some improvments over feedback * fix: Fix wrong evergreen check flag condition * feat: Added challenge drops to missionEnd screen + minor fixes * refactor: Removed writeUserData from challengeService The progressionService already stores the data, so theres no need to call it again here * fix: Prevent evergreenmastery unlock types from being awarded to the inventory * chore: Amend the explanation for getLocationMasteryDrops on evergreen type unlock * Update components/menus/planning.ts Co-authored-by: moonysolari <118079569+moonysolari@users.noreply.github.com> Signed-off-by: J0k3r-1 <128742169+J0k3r-1@users.noreply.github.com> * refactor: Award evergreenmastery but filter on inventory grant, like createInventory * refactor: Refactor challenge drop usage as they have been refactored from Unlockable[] to string[] * fix: Add mastery requirement for locked loadouts on Miami and Hokkaido + Add interface for sourcechallenge * chore: Remove console.log * fix: fixed H1 hokkaido not starting --------- Signed-off-by: J0k3r-1 <128742169+J0k3r-1@users.noreply.github.com> Co-authored-by: Reece Dunham <me@rdil.rocks> Co-authored-by: moonysolari <118079569+moonysolari@users.noreply.github.com>
1180 lines
39 KiB
TypeScript
1180 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 {
|
|
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
|
|
}
|
|
if (b.Score > a.Score) {
|
|
return 1
|
|
}
|
|
return 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)
|
|
|
|
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
|
|
}
|
|
|
|
if (
|
|
userData.Extensions.PeacockEscalations[eGroupId] ===
|
|
getLevelCount(controller.escalationMappings[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)
|
|
}
|
|
} else {
|
|
// not the final level
|
|
userData.Extensions.PeacockEscalations[eGroupId] += 1
|
|
}
|
|
|
|
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()
|
|
userData.Extensions.PeacockPlayedContracts[id].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.None,
|
|
},
|
|
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,
|
|
})
|
|
}
|