/* * 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 . */ 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 }>, ): 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 = { 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, res: Response, ): Promise { //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 = { PayoutsCompleted: [], PayoutsFailed: [], } if (contractData.Metadata.Type === "evergreen") { const gameChangerProperties = getConfig>( "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 = { 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, }) }