1
mirror of https://github.com/thepeacockproject/Peacock synced 2024-12-05 20:15:16 +01:00
Peacock/components/scoreHandler.ts
Reece Dunham 6245e91624 Initial commit
Co-authored-by: Tino Roivanen <tino.roivanen98@gmail.com>
Co-authored-by: Govert de Gans <grappigegovert@hotmail.com>
Co-authored-by: Gray Olson <gray@grayolson.com>
Co-authored-by: Alexandre Sanchez <alex73630@gmail.com>
Co-authored-by: Anthony Fuller <24512050+anthonyfuller@users.noreply.github.com>
Co-authored-by: atampy25 <24306974+atampy25@users.noreply.github.com>
Co-authored-by: David <davidstulemeijer@gmail.com>
Co-authored-by: c0derMo <c0dermo@users.noreply.github.com>
Co-authored-by: Jeevat Singh <jeevatt.singh@gmail.com>
Signed-off-by: Reece Dunham <me@rdil.rocks>
2022-10-19 21:33:45 -04:00

682 lines
23 KiB
TypeScript

/*
* The Peacock Project - a HITMAN server replacement.
* Copyright (C) 2021-2022 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 {
difficultyToString,
handleAxiosError,
isObjectiveActive,
PEACOCKVERSTRING,
xpRequiredForLevel,
} from "./utils"
import { contractSessions, getCurrentState } from "./eventHandler"
import { getConfig } from "./configSwizzleManager"
import { _theLastYardbirdScpc, controller } from "./controller"
import type {
ContractSession,
MissionManifestObjective,
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 } from "./contracts/dataGen"
import { liveSplitManager } from "./livesplit/liveSplitManager"
import { Playstyle, ScoringBonus, ScoringHeadline } from "./types/scoring"
import { MissionEndRequestQuery } from "./types/gameSchemas"
function calculatePlaystyle(session: ContractSession) {
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 async function missionEnd(
req: RequestWithJwt<MissionEndRequestQuery>,
res: Response,
): Promise<void> {
if (!req.query.contractSessionId) {
res.status(400).end()
return
}
const sessionDetails = contractSessions.get(req.query.contractSessionId)
if (!sessionDetails) {
// contract session not found
res.status(404).end()
return
}
if (sessionDetails.userId !== req.jwt.unique_name) {
// requested score for other user's session
res.status(401).end()
return
}
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
const contractData =
req.gameVersion === "scpc" &&
sessionDetails.contractId === "ff9f46cf-00bd-4c12-b887-eac491c3a96d"
? _theLastYardbirdScpc
: controller.resolveContract(sessionDetails.contractId)
if (!contractData) {
// contract not found
res.status(404).send("contract not found")
return
}
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)
}
const nonTargetKills =
contractData?.Metadata.AllowNonTargetKills === true
? 0
: sessionDetails.npcKills.size + sessionDetails.crowdNpcKills
// const allMissionStories = getConfig("MissionStories")
// const missionStories = (contractData.Metadata.Opportunities || []).map((missionStoryId) => allMissionStories[missionStoryId])
const batchedProgression =
controller.challengeService.getBatchChallengeProgression(
req.jwt.unique_name,
req.gameVersion,
)
const result = {
MissionReward: {
LocationProgression: {
LevelInfo: Array.from({ length: 1 }, (_, i) =>
xpRequiredForLevel(i + 1),
),
XP: 0,
Level: 1,
Completion: 1,
XPGain: 0,
HideProgression: false,
},
ProfileProgression: {
LevelInfo: [0, 6000],
LevelInfoOffset: 0,
XP: userData.Extensions.progression.PlayerProfileXP.Total,
Level: userData.Extensions.progression.PlayerProfileXP
.ProfileLevel,
XPGain: 0,
},
Challenges: Object.values(
controller.challengeService.getChallengesForContract(
sessionDetails.contractId,
req.gameVersion,
),
)
.flat()
// FIXME: This behaviour may not be accurate to original server
.filter(
(challengeData) =>
controller.challengeService.getChallengeProgression(
req.jwt.unique_name,
challengeData.Id,
req.gameVersion,
batchedProgression,
).Completed,
)
.map((challengeData) =>
controller.challengeService.compileRegistryChallengeTreeData(
challengeData,
controller.challengeService.getChallengeProgression(
req.jwt.unique_name,
challengeData.Id,
req.gameVersion,
batchedProgression,
),
req.gameVersion,
req.jwt.unique_name,
),
),
Drops: [],
OpportunityRewards: [], // ?
CompletionData: generateCompletionData(
contractData.Metadata.Location,
req.jwt.unique_name,
req.gameVersion,
),
ChallengeCompletion: {
ChallengesCount: 1,
CompletedChallengesCount: 0,
},
ContractChallengeCompletion: {
ChallengesCount: 1,
CompletedChallengesCount: 0,
},
OpportunityStatistics: {
Count: (contractData.Metadata.Opportunities || []).length,
Completed: 0,
},
LocationCompletionPercent: 0,
},
ScoreOverview: {
XP: 0,
Level: 1,
Completion: 1,
XPGain: 0,
ChallengesCompleted: 0,
LocationHideProgression: false,
ScoreDetails: {
Headlines: [] as ScoringHeadline[],
},
stars: 0,
SilentAssassin: false,
ContractScore: {
AchievedMasteries: [
{
score: -5000 * nonTargetKills,
RatioParts: nonTargetKills,
RatioTotal: nonTargetKills,
Id: "KillPenaltyMastery",
BaseScore: -5000,
},
],
TotalNoMultipliers: 0,
AwardedBonuses: [] as ScoringBonus[],
FailedBonuses: [] as ScoringBonus[],
Total: 0,
StarCount: 0,
SilentAssassin: false,
TimeUsedSecs: 0,
},
// todo
NewRank: 1,
RankCount: 1,
Rank: 1,
FriendsRankCount: 1,
FriendsRank: 1,
IsPartOfTopScores: false,
PlayStyle: {},
},
}
const bonuses = [
{
headline: "UI_SCORING_SUMMARY_OBJECTIVES",
bonusId: "AllObjectivesCompletedBonus",
condition:
req.gameVersion === "h1" ||
contractData.Metadata.Id ===
"2d1bada4-aa46-4954-8cf5-684989f1668a" ||
contractData.Data.Objectives?.every(
(obj: MissionManifestObjective) =>
obj.ExcludeFromScoring ||
sessionDetails.completedObjectives.has(obj.Id) ||
(obj.IgnoreIfInactive &&
!isObjectiveActive(
obj,
sessionDetails.completedObjectives,
)) ||
"Success" ===
getCurrentState(
req.query.contractSessionId!,
obj.Id,
),
),
},
{
headline: "UI_SCORING_SUMMARY_NOT_SPOTTED",
bonusId: "Unspotted",
condition: [
...sessionDetails.witnesses,
...sessionDetails.spottedBy,
].every(
(witness) =>
(req.gameVersion === "h1"
? false
: sessionDetails.targetKills.has(witness)) ||
sessionDetails.npcKills.has(witness),
),
},
{
headline: "UI_SCORING_SUMMARY_NO_NOTICED_KILLS",
bonusId: "NoWitnessedKillsBonus",
condition: [...sessionDetails.killsNoticedBy].every(
(witness) =>
(req.gameVersion === "h1"
? true
: sessionDetails.targetKills.has(witness)) ||
sessionDetails.npcKills.has(witness),
),
},
{
headline: "UI_SCORING_SUMMARY_NO_BODIES_FOUND",
bonusId: "NoBodiesFound",
condition:
sessionDetails.legacyHasBodyBeenFound === false &&
[...sessionDetails.bodiesFoundBy].every(
(witness) =>
(req.gameVersion === "h1"
? false
: sessionDetails.targetKills.has(witness)) ||
sessionDetails.npcKills.has(witness),
),
},
{
headline: "UI_SCORING_SUMMARY_NO_RECORDINGS",
bonusId: "SecurityErased",
condition:
sessionDetails.recording === "NOT_SPOTTED" ||
sessionDetails.recording === "ERASED",
},
]
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
let total = -5000 * nonTargetKills
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: 0,
FractionDenominator: 0,
}
const headlineObj = Object.assign(
{},
headlineObjTemplate,
) as ScoringHeadline
headlineObj.headline = bonus.headline
if (bonus.condition) {
total += 20000
result.ScoreOverview.ScoreDetails.Headlines.push(headlineObj)
result.ScoreOverview.ContractScore.AwardedBonuses.push(bonusObj)
} else {
bonusObj.Score = 0
headlineObj.scoreTotal = 0
result.ScoreOverview.ScoreDetails.Headlines.push(headlineObj)
result.ScoreOverview.ContractScore.FailedBonuses.push(bonusObj)
}
}
total = Math.max(total, 0)
result.ScoreOverview.ContractScore.TotalNoMultipliers =
result.ScoreOverview.ContractScore.Total = total
result.ScoreOverview.ScoreDetails.Headlines.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 timeTotal: Seconds =
(sessionDetails.timerEnd as number) -
(sessionDetails.timerStart as number)
result.ScoreOverview.ContractScore.TimeUsedSecs = timeTotal
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 = total * bonusMultiplier
break
}
timebonus = Math.round(timebonus)
total += timebonus
result.ScoreOverview.ContractScore.AwardedBonuses.push({
Score: timebonus,
Id: "SwiftExecution",
FractionNumerator: 0,
FractionDenominator: 0,
})
result.ScoreOverview.ScoreDetails.Headlines.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"]) {
result.ScoreOverview.ScoreDetails.Headlines.push(
Object.assign(Object.assign({}, headlineObjTemplate), {
type,
headline: `UI_SCORING_SUMMARY_${type.toUpperCase()}`,
scoreTotal: total,
}) as ScoringHeadline,
)
}
result.ScoreOverview.stars = result.ScoreOverview.ContractScore.StarCount =
stars
result.ScoreOverview.SilentAssassin =
result.ScoreOverview.ContractScore.SilentAssassin = [
...bonuses.slice(1),
{ condition: nonTargetKills === 0 },
].every((x) => x.condition) // need to have all bonuses except objectives for SA
if ((getFlag("autoSplitterForceSilentAssassin") as boolean) === true) {
if (result.ScoreOverview.SilentAssassin) {
await liveSplitManager.completeMission(timeTotal)
} else {
await liveSplitManager.failMission(timeTotal)
}
} else {
await liveSplitManager.completeMission(timeTotal)
}
// Playstyles
const calculatedPlaystyles = calculatePlaystyle(sessionDetails)
if (calculatedPlaystyles[0].Score !== 0) {
result.ScoreOverview.PlayStyle = calculatedPlaystyles[0]
}
//#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: total,
data: {
Score: {
Total: total,
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: 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,
})
}