mirror of
https://github.com/thepeacockproject/Peacock
synced 2024-12-12 07:23:53 +01:00
46052c7b0e
* Add multi-version mastery files * Add pro1 unlocks to legacy allunlockables * Add 47's suit to scpc all unlockables * Add and remove various configs * Remove some useless promises * Fix scpc hub * Fix issue with user profile saving * Fix scpc issues for hub * Add singleplayer/multiplayer sniper * A great many things - Add multi-version mastery - Improve sniper mastery support - Improve general H2016 support * Fix some warnings * Fix pro1 mastery on destination screens * Remove entP from createInventory, lock/unlock pro1 accordingly * Remove JSDoc entP parameter from createInventory * Remove difficultyunlocks from safehouse pages * Add versioned user profiles * Prettier run * Remove false point from user profiles docs * Add comment about profile versioning to types * Fix default profile links * Remove remaining lowercase * Fix sniper showing XP as XP * Add game versions to the unlockable map * Update getMasteryForUnlockable call in planning * Fix missing locations when updating profiles * Update versions to v7 * Fix ICA Facility destination mastery * Fix sniper challenge unlockables showing in inventory * Sniper Scoring (#273) * Initial sniper scoring * Fix linting errors * Update require table * Calculate and display final sniper score on end screen * Bump SMP version to v5.7.0 * Update since version for scoring * Fix create inventory call for sniper scoring * Support sniper unlockables in the inventory * Update versions to v7 * Reflect changes to createInventory in scoreHandler * Get unlockable name in completion data * It was not okay. * Thanks webstorm * Add support for /profiles/page/GetMasteryCompletionDataForUnlockable * Support sniper play next * Remove sniper gamemodes template from overrides * Remove debug prints from scoring event handler * Fix challenge multiplier * Exclude sniper unlockables from stashpoint * Start fixing up the missionEnd response for sniper * Update misleading comment * Use existing global challenge to check for SA on sniper contracts * Re-add removed global challenges * Proper support for the mission end screen on sniper contracts * Remove redundant label --------- Signed-off-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com> Co-authored-by: Govert de Gans <grappigegovert@hotmail.com> * Add co-op sniper scoring defs * Update MasteryUnlockable template * Bump SMP version to v5.9.3 * Re-add deepmerge * Fix SMP checksum * Fix linting errors caused by merge * Fix score handler imports * Move load flags * Remove unnecessary game version arg * Whoopsies Co-authored-by: Reece Dunham <me@rdil.rocks> Signed-off-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com> --------- Signed-off-by: Anthony Fuller <24512050+AnthonyFuller@users.noreply.github.com> Co-authored-by: Govert de Gans <grappigegovert@hotmail.com> Co-authored-by: Reece Dunham <me@rdil.rocks>
1052 lines
33 KiB
TypeScript
1052 lines
33 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 { Router } from "express"
|
|
import {
|
|
ClientToServerEvent,
|
|
ContractProgressionData,
|
|
ContractSession,
|
|
GameVersion,
|
|
MissionManifestObjective,
|
|
PeacockCameraStatus,
|
|
PushMessage,
|
|
RatingKill,
|
|
RequestWithJwt,
|
|
S2CEventWithTimestamp,
|
|
Seconds,
|
|
ServerToClientEvent,
|
|
} from "./types/types"
|
|
import { contractTypes, extractToken, gameDifficulty, ServerVer } from "./utils"
|
|
import { json as jsonMiddleware } from "body-parser"
|
|
import { log, LogLevel } from "./loggingInterop"
|
|
import { getUserData, writeUserData } from "./databaseHandler"
|
|
import { controller } from "./controller"
|
|
import { swapToLocationStatus } from "./discordRp"
|
|
import { randomUUID } from "crypto"
|
|
import { liveSplitManager } from "./livesplit/liveSplitManager"
|
|
import { handleMultiplayerEvent } from "./multiplayer/multiplayerService"
|
|
import { handleEvent } from "@peacockproject/statemachine-parser"
|
|
import { encodePushMessage } from "./multiplayer/multiplayerUtils"
|
|
import {
|
|
ActorTaggedC2SEvent,
|
|
AmbientChangedC2SEvent,
|
|
AreaDiscoveredC2SEvent,
|
|
BodyHiddenC2SEvent,
|
|
ContractStartC2SEvent,
|
|
Evergreen_Payout_DataC2SEvent,
|
|
HeroSpawn_LocationC2SEvent,
|
|
ItemDroppedC2SEvent,
|
|
ItemPickedUpC2SEvent,
|
|
KillC2SEvent,
|
|
MurderedBodySeenC2SEvent,
|
|
ObjectiveCompletedC2SEvent,
|
|
OpportunityEventsC2SEvent,
|
|
PacifyC2SEvent,
|
|
SecuritySystemRecorderC2SEvent,
|
|
SetpiecesC2SEvent,
|
|
SpottedC2SEvent,
|
|
WitnessesC2SEvent,
|
|
} from "./types/events"
|
|
import picocolors from "picocolors"
|
|
import { setCpd } from "./evergreen"
|
|
import { getConfig } from "./configSwizzleManager"
|
|
import { resetUserEscalationProgress } from "./contracts/escalations/escalationService"
|
|
import {
|
|
ManifestScoringDefinition,
|
|
ManifestScoringModule,
|
|
} from "./types/scoring"
|
|
import { deepmerge } from "deepmerge-ts"
|
|
|
|
const eventRouter = Router()
|
|
|
|
// /authentication/api/userchannel/EventsService/
|
|
|
|
const eventQueue = new Map<string, S2CEventWithTimestamp[]>()
|
|
const pushMessageQueue = new Map<string, PushMessage[]>()
|
|
|
|
/**
|
|
* Enqueue a server to client push message.
|
|
* It will be sent back the next time the client calls `SaveAndSynchronizeEvents4`.
|
|
*
|
|
* @param userId The push message's target user.
|
|
* @param message The raw push message to send.
|
|
* @see enqueueEvent
|
|
* @author grappigegovert
|
|
*/
|
|
export function enqueuePushMessage(userId: string, message: unknown): void {
|
|
let userQueue
|
|
const time = process.hrtime.bigint()
|
|
|
|
if ((userQueue = pushMessageQueue.get(userId))) {
|
|
userQueue.push({
|
|
time,
|
|
message: encodePushMessage(time, message),
|
|
})
|
|
} else {
|
|
userQueue = [
|
|
{
|
|
time,
|
|
message: encodePushMessage(time, message),
|
|
},
|
|
]
|
|
pushMessageQueue.set(userId, userQueue)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Register a listener for an objective. Allows server-side tracking of the objective's state as events come in.
|
|
*
|
|
* @param session The contract session.
|
|
* @param objective The objective object.
|
|
* @author Reece Dunham
|
|
*/
|
|
export function registerObjectiveListener(
|
|
session: ContractSession,
|
|
objective: MissionManifestObjective,
|
|
): void {
|
|
if (!objective.Definition) {
|
|
return
|
|
}
|
|
|
|
let context = objective.Definition.Context || {}
|
|
let state = "Start"
|
|
|
|
session.objectiveDefinitions.set(objective.Id, objective.Definition)
|
|
|
|
const immediate = handleEvent(
|
|
// @ts-expect-error Type issue, needs to be corrected in sm-p.
|
|
objective.Definition,
|
|
context,
|
|
{},
|
|
{
|
|
eventName: "-",
|
|
currentState: state,
|
|
},
|
|
)
|
|
|
|
if (immediate.state) {
|
|
state = immediate.state
|
|
}
|
|
|
|
if (immediate.context) {
|
|
context = immediate.context
|
|
}
|
|
|
|
session.objectiveContexts.set(objective.Id, context)
|
|
session.objectiveStates.set(objective.Id, state)
|
|
}
|
|
|
|
/**
|
|
* Sets up scoring state machines.
|
|
*
|
|
* @param session The contract session.
|
|
* @param modules Array of scoring modules.
|
|
*/
|
|
export function setupScoring(
|
|
session: ContractSession,
|
|
modules: ManifestScoringModule[],
|
|
): void {
|
|
const scoring = {
|
|
Settings: {},
|
|
Context: undefined,
|
|
Definition: undefined,
|
|
State: undefined,
|
|
Timers: [],
|
|
}
|
|
|
|
for (const module of modules) {
|
|
const name = module.Type.split(".").at(-1)
|
|
|
|
if (name === "scoring") {
|
|
const definition: ManifestScoringDefinition = deepmerge(
|
|
...module.ScoringDefinitions,
|
|
)
|
|
|
|
let state = "Start"
|
|
let context = definition.Context
|
|
|
|
const immediate = handleEvent(
|
|
// @ts-expect-error Type issue
|
|
definition,
|
|
context,
|
|
{},
|
|
{
|
|
eventName: "-",
|
|
currentState: state,
|
|
timers: scoring.Timers,
|
|
},
|
|
)
|
|
|
|
if (immediate.state) {
|
|
state = immediate.state
|
|
}
|
|
|
|
if (immediate.context) {
|
|
context = immediate.context
|
|
}
|
|
|
|
scoring.Definition = definition
|
|
scoring.Context = context
|
|
scoring.State = state
|
|
} else {
|
|
scoring.Settings[name] = module
|
|
delete scoring.Settings[name]["Type"]
|
|
}
|
|
}
|
|
|
|
session.scoring = scoring
|
|
}
|
|
|
|
/**
|
|
* Enqueue a server to client event.
|
|
* It will be sent back the next time the client calls `SaveAndSynchronizeEvents4`.
|
|
*
|
|
* @param userId The event's target user.
|
|
* @param event The event to send.
|
|
* @see enqueuePushMessage
|
|
* @author grappigegovert
|
|
*/
|
|
export function enqueueEvent(userId: string, event: ServerToClientEvent): void {
|
|
let userQueue: S2CEventWithTimestamp[] | undefined
|
|
const time = process.hrtime.bigint().toString()
|
|
event.CreatedAt = new Date().toISOString().slice(0, -1)
|
|
event.Token = time.toString()
|
|
|
|
if ((userQueue = eventQueue.get(userId))) {
|
|
userQueue.push({
|
|
time,
|
|
event,
|
|
})
|
|
} else {
|
|
userQueue = [
|
|
{
|
|
time,
|
|
event,
|
|
},
|
|
]
|
|
eventQueue.set(userId, userQueue)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Like the game's internal enum for EDeathContext.
|
|
*
|
|
* @see https://github.com/OrfeasZ/ZHMModSDK/blob/ba9512092a37d3b1f4de047bdd6acf15a7b9ac7c/ZHMModSDK/Include/Glacier/Enums.h#L6158
|
|
*/
|
|
export const enum EDeathContext {
|
|
eDC_UNDEFINED = 0,
|
|
eDC_NOT_HERO = 1,
|
|
eDC_HIDDEN = 2,
|
|
eDC_ACCIDENT = 3,
|
|
eDC_MURDER = 4,
|
|
}
|
|
|
|
export const contractSessions = new Map<string, ContractSession>()
|
|
const userIdToTempSession = new Map<string, string>()
|
|
|
|
/**
|
|
* Get the current state of an objective.
|
|
*
|
|
* @param sessionId The session ID.
|
|
* @param objectiveId The objective ID.
|
|
*/
|
|
export function getCurrentState(
|
|
sessionId: string,
|
|
objectiveId: string,
|
|
): string | undefined {
|
|
// Note: after the double-layered maps are merged into the session object, this should be rewritten.
|
|
const session = contractSessions.get(sessionId)
|
|
|
|
if (!session) {
|
|
return "Start"
|
|
}
|
|
|
|
return session.objectiveStates.get(objectiveId)
|
|
}
|
|
|
|
/**
|
|
* Creates a new contract session.
|
|
*
|
|
* @param sessionId The ID for the session.
|
|
* @param contractId The ID of the contract the session is for.
|
|
* @param userId The ID of the user playing the session.
|
|
* @param difficulty The difficulty of the game.
|
|
* @param gameVersion The game version.
|
|
* @param doScoring If true, this will be treated like a normal session. If false, this session will not be scored/put on the leaderboards. This should be false if we don't have full session details, e.g. if this is a save from the official servers loaded on Peacock.
|
|
*/
|
|
export function newSession(
|
|
sessionId: string,
|
|
contractId: string,
|
|
userId: string,
|
|
difficulty: number,
|
|
gameVersion: GameVersion,
|
|
doScoring = true,
|
|
): void {
|
|
const timestamp = new Date()
|
|
|
|
const contract = controller.resolveContract(contractId)
|
|
|
|
if (!contract) {
|
|
log(LogLevel.ERROR, `Failed to load ${contractId}`)
|
|
throw new Error("no ct")
|
|
}
|
|
|
|
if (difficulty === 0 && contractTypes.includes(contract.Metadata.Type)) {
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Difficulty not set for user created contract ${contractId}, setting to 2`,
|
|
)
|
|
difficulty = 2
|
|
}
|
|
|
|
swapToLocationStatus(
|
|
contract.Metadata.ScenePath,
|
|
contract.Metadata.Type,
|
|
contract.Data.Bricks || [],
|
|
)
|
|
|
|
contractSessions.set(sessionId, {
|
|
Id: sessionId,
|
|
gameVersion,
|
|
sessionStart: timestamp,
|
|
lastUpdate: timestamp,
|
|
contractId,
|
|
userId: userId,
|
|
timerStart: 0,
|
|
timerEnd: 0,
|
|
duration: 0,
|
|
crowdNpcKills: 0,
|
|
targetKills: new Set(),
|
|
npcKills: new Set(),
|
|
bodiesHidden: new Set(),
|
|
pacifications: new Set(),
|
|
disguisesUsed: new Set(),
|
|
disguisesRuined: new Set(),
|
|
spottedBy: new Set(),
|
|
witnesses: new Set(),
|
|
bodiesFoundBy: new Set(),
|
|
legacyHasBodyBeenFound: false,
|
|
killsNoticedBy: new Set(),
|
|
completedObjectives: new Set(),
|
|
failedObjectives: new Set(),
|
|
recording: PeacockCameraStatus.NotSpotted,
|
|
lastAccident: 0,
|
|
lastKill: {},
|
|
kills: new Set(),
|
|
compat: doScoring,
|
|
markedTargets: new Set(),
|
|
currentDisguise: "4fc9396e-2619-4e66-a51e-2bd366230da7", // sig suit
|
|
difficulty,
|
|
objectiveContexts: new Map(),
|
|
objectiveStates: new Map(),
|
|
objectiveDefinitions: new Map(),
|
|
ghost: {
|
|
deaths: 0,
|
|
unnoticedKills: 0,
|
|
Opponents: [],
|
|
OpponentScore: 0,
|
|
Score: 0,
|
|
IsDraw: false,
|
|
IsWinner: false,
|
|
timerEnd: null,
|
|
},
|
|
challengeContexts: {},
|
|
})
|
|
userIdToTempSession.set(userId, sessionId)
|
|
|
|
controller.challengeService.startContract(
|
|
userId,
|
|
contractSessions.get(sessionId)!,
|
|
)
|
|
}
|
|
|
|
eventRouter.post(
|
|
"/SaveAndSynchronizeEvents4",
|
|
extractToken,
|
|
jsonMiddleware({ limit: "10Mb" }),
|
|
(
|
|
req: RequestWithJwt<
|
|
unknown,
|
|
{
|
|
lastPushDt: number | string
|
|
lastEventTicks: number | string
|
|
userId?: string
|
|
values?: []
|
|
}
|
|
>,
|
|
res,
|
|
) => {
|
|
if (req.body.userId !== req.jwt.unique_name) {
|
|
res.status(403).send() // Trying to save events for other user
|
|
return
|
|
}
|
|
|
|
if (!Array.isArray(req.body.values)) {
|
|
res.status(400).end() // malformed request
|
|
return
|
|
}
|
|
|
|
const savedTokens = req.body.values.length
|
|
? saveEvents(req.body.userId, req.body.values, req)
|
|
: null
|
|
|
|
let userQueue: S2CEventWithTimestamp[] | undefined
|
|
let newEvents: ServerToClientEvent[] | null = null
|
|
|
|
// events: (server -> client)
|
|
if ((userQueue = eventQueue.get(req.jwt.unique_name))) {
|
|
userQueue = userQueue.filter(
|
|
(item) => item.time > req.body.lastEventTicks,
|
|
)
|
|
eventQueue.set(req.jwt.unique_name, userQueue)
|
|
|
|
newEvents = Array.from(userQueue, (item) => item.event)
|
|
}
|
|
|
|
// push messages: (server -> client)
|
|
let userPushQueue: PushMessage[] | undefined
|
|
let pushMessages: string[] | null = null
|
|
|
|
if ((userPushQueue = pushMessageQueue.get(req.jwt.unique_name))) {
|
|
userPushQueue = userPushQueue.filter(
|
|
(item) => item.time > req.body.lastPushDt,
|
|
)
|
|
pushMessageQueue.set(req.jwt.unique_name, userPushQueue)
|
|
|
|
pushMessages = Array.from(userPushQueue, (item) => item.message)
|
|
}
|
|
|
|
res.json({
|
|
SavedTokens: savedTokens,
|
|
NewEvents: newEvents || null,
|
|
NextPoll: 10.0,
|
|
PushMessages: pushMessages || null,
|
|
})
|
|
},
|
|
)
|
|
|
|
eventRouter.post(
|
|
"/SaveEvents2",
|
|
extractToken,
|
|
jsonMiddleware({ limit: "10Mb" }),
|
|
(req: RequestWithJwt, res) => {
|
|
if (req.jwt.unique_name !== req.body.userId) {
|
|
res.status(403).send() // Trying to save events for other user
|
|
return
|
|
}
|
|
|
|
res.json(saveEvents(req.body.userId, req.body.values, req))
|
|
},
|
|
)
|
|
|
|
/**
|
|
* Gets the active session's ID for the specified user (by their ID).
|
|
*
|
|
* @param uId The user's ID.
|
|
* @returns The ID for the user's active session.
|
|
* @author Reece Dunham
|
|
*/
|
|
export function getActiveSessionIdForUser(uId: string): string | undefined {
|
|
return userIdToTempSession.get(uId)
|
|
}
|
|
|
|
/**
|
|
* Gets the active session for the specified user (by their ID).
|
|
*
|
|
* @param uId The user's ID.
|
|
* @returns The user's active contract session.
|
|
* @author Reece Dunham
|
|
*/
|
|
export function getSession(uId: string): ContractSession | undefined {
|
|
const currentSession = getActiveSessionIdForUser(uId)
|
|
|
|
if (!currentSession) {
|
|
return undefined
|
|
}
|
|
|
|
return contractSessions.get(currentSession)
|
|
}
|
|
|
|
function contractFailed(
|
|
event: ClientToServerEvent,
|
|
session: ContractSession,
|
|
): void {
|
|
session.markedTargets.clear()
|
|
|
|
const json = controller.resolveContract(session.contractId)!
|
|
const userData = getUserData(session.userId, session.gameVersion)
|
|
|
|
let realName: string
|
|
|
|
if (json.Metadata.Type === "creation") {
|
|
realName =
|
|
event.Value === "Contract ended manually: OnRestartLevel"
|
|
? "GameRestart"
|
|
: `ContractFailed:${event.Value}`
|
|
} else {
|
|
realName = `ContractFailed:${event.Value}`
|
|
}
|
|
|
|
// if still in cutscene, end mission with 0 time pass -- this will get converted to minimum time within split manager
|
|
if (session.timerStart !== 0) {
|
|
// @ts-expect-error TypeScript still hates dates
|
|
const timeTotal: Seconds = session.timerEnd - session.timerStart
|
|
liveSplitManager.failMission(timeTotal)
|
|
} else {
|
|
liveSplitManager.failMission(0)
|
|
}
|
|
|
|
// If this is a contract, update the contract in the played list
|
|
if (contractTypes.includes(json.Metadata.Type)) {
|
|
const id = session.contractId
|
|
|
|
if (!userData.Extensions.PeacockPlayedContracts[id]) {
|
|
userData.Extensions.PeacockPlayedContracts[id] = {}
|
|
}
|
|
|
|
userData.Extensions.PeacockPlayedContracts[id].LastPlayedAt =
|
|
new Date().getTime()
|
|
writeUserData(session.userId, session.gameVersion)
|
|
}
|
|
|
|
// If this is an arcade contract, reset it
|
|
arcadeFail: if (json.Metadata.Type === "arcade") {
|
|
manualExit: if (
|
|
typeof event.Value === "string" &&
|
|
event.Value.startsWith("Contract ended manually")
|
|
) {
|
|
if (session.completedObjectives.size === 0) break arcadeFail
|
|
|
|
for (const obj of json.Data.Objectives) {
|
|
if (
|
|
session.completedObjectives.has(obj.Id) &&
|
|
obj.Category === "primary"
|
|
) {
|
|
break manualExit
|
|
}
|
|
}
|
|
|
|
// Any completed objectives are secondary gamechangers, so we don't need to reset the contract
|
|
break arcadeFail
|
|
}
|
|
|
|
const escalationGroupId = json.Metadata.InGroup ?? json.Metadata.Id
|
|
|
|
resetUserEscalationProgress(userData, escalationGroupId)
|
|
|
|
writeUserData(session.userId, session.gameVersion)
|
|
}
|
|
|
|
enqueueEvent(session.userId, {
|
|
CreatedAt: new Date().toISOString(),
|
|
Token: process.hrtime.bigint().toString(),
|
|
Id: randomUUID(),
|
|
Name: "SegmentClosing",
|
|
UserId: session.userId,
|
|
ContractId: session.contractId,
|
|
SessionId: null,
|
|
ContractSessionId: event.ContractSessionId,
|
|
Timestamp: 0.0,
|
|
Value: {
|
|
SegmentIndex: 0,
|
|
LastEventName: "ContractFailed",
|
|
LastEventTime: (session.lastUpdate as Date).toISOString(),
|
|
CloseType: realName,
|
|
},
|
|
Origin: "ContractSessionService",
|
|
Version: ServerVer,
|
|
IsReplicated: false,
|
|
})
|
|
}
|
|
|
|
function saveEvents(
|
|
userId: string,
|
|
events: ClientToServerEvent[],
|
|
req: RequestWithJwt<unknown, unknown>,
|
|
): string[] {
|
|
const response: string[] = []
|
|
const processed: string[] = []
|
|
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
|
|
events.forEach((event) => {
|
|
let session = contractSessions.get(event.ContractSessionId)
|
|
|
|
if (!session) {
|
|
log(
|
|
LogLevel.WARN,
|
|
"Creating a fake session to avoid problems... scoring will not work!",
|
|
)
|
|
|
|
newSession(
|
|
event.ContractSessionId,
|
|
event.ContractId,
|
|
req.jwt.unique_name,
|
|
gameDifficulty.normal,
|
|
req.gameVersion,
|
|
false,
|
|
)
|
|
|
|
session = contractSessions.get(event.ContractSessionId)
|
|
}
|
|
|
|
if (
|
|
!session ||
|
|
session.contractId !== event.ContractId ||
|
|
session.userId !== userId
|
|
) {
|
|
if (PEACOCK_DEV) {
|
|
log(LogLevel.DEBUG, "No session or session user ID mismatch!")
|
|
console.debug(session)
|
|
console.debug(event)
|
|
}
|
|
|
|
return // session does not exist or contractid/userid doesn't match
|
|
}
|
|
|
|
session.duration = event.Timestamp
|
|
session.lastUpdate = new Date()
|
|
|
|
const contract = controller.resolveContract(session.contractId)
|
|
const contractType = contract?.Metadata?.Type?.toLowerCase()
|
|
|
|
// @ts-expect-error Issue with request type mismatch.
|
|
controller.hooks.newEvent.call(event, req, session)
|
|
|
|
for (const objectiveId of session.objectiveStates.keys()) {
|
|
try {
|
|
const objectiveDefinition =
|
|
session.objectiveDefinitions.get(objectiveId)
|
|
const objectiveState = session.objectiveStates.get(objectiveId)
|
|
const objectiveContext =
|
|
session.objectiveContexts.get(objectiveId)
|
|
|
|
const val = handleEvent(
|
|
objectiveDefinition as never,
|
|
objectiveContext,
|
|
event.Value,
|
|
{
|
|
eventName: event.Name,
|
|
currentState: objectiveState,
|
|
timestamp: event.Timestamp,
|
|
},
|
|
)
|
|
|
|
if (val.state === "Failure") {
|
|
if (PEACOCK_DEV && contractType !== "evergreen") {
|
|
log(LogLevel.DEBUG, `Objective failed: ${objectiveId}`)
|
|
}
|
|
|
|
session.failedObjectives.add(objectiveId)
|
|
}
|
|
|
|
if (val.context) {
|
|
session.objectiveContexts.set(objectiveId, val.context)
|
|
session.objectiveStates.set(objectiveId, val.state)
|
|
}
|
|
} catch (e) {
|
|
log(
|
|
LogLevel.ERROR,
|
|
"An error occurred while tracing C2S events, please report this!",
|
|
)
|
|
log(LogLevel.ERROR, e)
|
|
log(LogLevel.ERROR, e.stack)
|
|
}
|
|
}
|
|
|
|
if (session.scoring) {
|
|
const scoringContext = session.scoring.Context
|
|
const scoringState = session.scoring.State
|
|
|
|
const val = handleEvent(
|
|
session.scoring.Definition as never,
|
|
scoringContext,
|
|
event.Value,
|
|
{
|
|
eventName: event.Name,
|
|
timestamp: event.Timestamp,
|
|
currentState: scoringState,
|
|
timers: session.scoring.Timers,
|
|
},
|
|
)
|
|
|
|
if (val.context) {
|
|
session.scoring.Context = val.context
|
|
session.scoring.State = val.state
|
|
}
|
|
}
|
|
|
|
controller.challengeService.onContractEvent(event, session)
|
|
|
|
if (event.Name.startsWith("ScoringScreenEndState_")) {
|
|
session.evergreen.scoringScreenEndState = event.Name
|
|
|
|
processed.push(event.Name)
|
|
response.push(process.hrtime.bigint().toString())
|
|
|
|
return
|
|
}
|
|
|
|
// these events are important but may be fired after the timer is over
|
|
const canGetAfterTimerOver = [
|
|
"ContractEnd",
|
|
"ObjectiveCompleted",
|
|
"CpdSet",
|
|
"MissionFailed_Event",
|
|
]
|
|
|
|
if (
|
|
!canGetAfterTimerOver.includes(event.Name) &&
|
|
session.timerEnd !== 0 &&
|
|
event.Timestamp > (session.timerEnd as number)
|
|
) {
|
|
// Do not handle events that occur after exiting the level
|
|
response.push(process.hrtime.bigint().toString())
|
|
return
|
|
}
|
|
|
|
if (handleMultiplayerEvent(event, session)) {
|
|
processed.push(event.Name)
|
|
response.push(process.hrtime.bigint().toString())
|
|
|
|
return
|
|
}
|
|
|
|
switch (event.Name) {
|
|
case "HeroSpawn_Location":
|
|
liveSplitManager.missionIntentResolved(
|
|
event.ContractId,
|
|
(<HeroSpawn_LocationC2SEvent>event).Value.RepositoryId,
|
|
)
|
|
break
|
|
case "Kill": {
|
|
const killValue = (event as KillC2SEvent).Value
|
|
|
|
if (session.firstKillTimestamp === undefined) {
|
|
session.firstKillTimestamp = event.Timestamp
|
|
}
|
|
|
|
if (session.lastKill.timestamp === event.Timestamp) {
|
|
session.lastKill.repositoryIds?.push(killValue.RepositoryId)
|
|
} else {
|
|
session.lastKill = {
|
|
timestamp: event.Timestamp,
|
|
repositoryIds: [killValue.RepositoryId],
|
|
}
|
|
}
|
|
|
|
if (killValue.KillContext === EDeathContext.eDC_NOT_HERO) {
|
|
// this is not 47, so we keep silent assassin
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`${killValue.RepositoryId} eliminated, 47 not responsible`,
|
|
)
|
|
response.push(process.hrtime.bigint().toString())
|
|
return
|
|
}
|
|
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Actor ${killValue.RepositoryId} eliminated.`,
|
|
)
|
|
|
|
if (killValue.IsTarget || contractType === "creation") {
|
|
const kill: RatingKill = {
|
|
KillClass: killValue.KillClass,
|
|
KillMethodBroad: killValue.KillMethodBroad,
|
|
KillItemCategory: killValue.KillItemCategory,
|
|
IsHeadshot: killValue.IsHeadshot,
|
|
KillMethodStrict: killValue.KillMethodStrict,
|
|
KillItemRepositoryId: killValue.KillItemRepositoryId,
|
|
_RepositoryId: killValue.RepositoryId,
|
|
OutfitRepoId: session.currentDisguise,
|
|
}
|
|
|
|
session.kills.add(kill)
|
|
|
|
session.targetKills.add(killValue.RepositoryId)
|
|
} else {
|
|
session.npcKills.add(killValue.RepositoryId)
|
|
}
|
|
|
|
break
|
|
}
|
|
case "CrowdNPC_Died":
|
|
session.crowdNpcKills += 1
|
|
break
|
|
case "Pacify":
|
|
session.pacifications.add(
|
|
(<PacifyC2SEvent>event).Value.RepositoryId,
|
|
)
|
|
break
|
|
case "BodyHidden":
|
|
session.bodiesHidden.add(
|
|
(<BodyHiddenC2SEvent>event).Value.RepositoryId,
|
|
)
|
|
break
|
|
case "BodyFound":
|
|
if (req.gameVersion === "h1") {
|
|
session.legacyHasBodyBeenFound = true
|
|
}
|
|
|
|
break
|
|
case "Disguise":
|
|
log(LogLevel.DEBUG, `Now disguised: ${event.Value as string}`)
|
|
session.currentDisguise = event.Value as string
|
|
session.disguisesUsed.add(event.Value as string)
|
|
break
|
|
case "ContractStart": {
|
|
const disguise = (<ContractStartC2SEvent>event).Value.Disguise
|
|
|
|
session.currentDisguise = disguise
|
|
session.disguisesUsed.add(disguise)
|
|
liveSplitManager.startMission(
|
|
session.contractId,
|
|
req.gameVersion,
|
|
req.jwt.unique_name,
|
|
)
|
|
break
|
|
}
|
|
case "DisguiseBlown":
|
|
session.disguisesRuined.add(event.Value as string)
|
|
break
|
|
case "BrokenDisguiseCleared":
|
|
session.disguisesRuined.delete(event.Value as string)
|
|
break
|
|
case "Spotted":
|
|
for (const actor of (event as SpottedC2SEvent).Value) {
|
|
session.spottedBy.add(actor)
|
|
}
|
|
|
|
break
|
|
case "Witnesses":
|
|
for (const actor of (event as WitnessesC2SEvent).Value) {
|
|
session.witnesses.add(actor)
|
|
}
|
|
|
|
break
|
|
case "SecuritySystemRecorder": {
|
|
const eventValue = (<SecuritySystemRecorderC2SEvent>event).Value
|
|
|
|
if (
|
|
eventValue.event === "spotted" &&
|
|
session.recording !== PeacockCameraStatus.Erased
|
|
) {
|
|
session.recording = PeacockCameraStatus.Spotted
|
|
} else if (
|
|
eventValue.event === "destroyed" ||
|
|
eventValue.event === "erased"
|
|
) {
|
|
session.recording = PeacockCameraStatus.Erased
|
|
}
|
|
|
|
break
|
|
}
|
|
case "IntroCutEnd":
|
|
if (!session.timerStart) {
|
|
session.timerStart = event.Timestamp
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Mission started at: ${session.timerStart}`,
|
|
)
|
|
}
|
|
|
|
break
|
|
case "exit_gate":
|
|
session.timerEnd = event.Timestamp
|
|
log(LogLevel.DEBUG, `Mission ended at: ${session.timerEnd}`)
|
|
break
|
|
case "ContractEnd":
|
|
if (!session.timerEnd) {
|
|
session.timerEnd = event.Timestamp
|
|
log(LogLevel.DEBUG, `Mission ended at: ${session.timerEnd}`)
|
|
}
|
|
|
|
break
|
|
case "ObjectiveCompleted":
|
|
session.completedObjectives.add(
|
|
(<ObjectiveCompletedC2SEvent>event).Value.Id,
|
|
)
|
|
break
|
|
case "AccidentBodyFound":
|
|
session.lastAccident = event.Timestamp
|
|
break
|
|
case "MurderedBodySeen":
|
|
if (
|
|
(event.Timestamp as unknown as number) !==
|
|
session.lastAccident
|
|
) {
|
|
session.bodiesFoundBy.add(
|
|
(<MurderedBodySeenC2SEvent>event).Value.Witness,
|
|
)
|
|
|
|
if (event.Timestamp === session.lastKill.timestamp) {
|
|
session.killsNoticedBy.add(
|
|
(<MurderedBodySeenC2SEvent>event).Value.Witness,
|
|
)
|
|
}
|
|
}
|
|
|
|
break
|
|
case "ActorTagged": {
|
|
const val = (<ActorTaggedC2SEvent>event).Value
|
|
|
|
if (!val.Tagged) {
|
|
session.markedTargets.delete(val.RepositoryId)
|
|
} else if (val.Tagged) {
|
|
session.markedTargets.add(val.RepositoryId)
|
|
}
|
|
|
|
break
|
|
}
|
|
case "StartingSuit":
|
|
session.currentDisguise = event.Value as string
|
|
break
|
|
case "ContractFailed":
|
|
session.timerEnd = event.Timestamp
|
|
contractFailed(event, session)
|
|
break
|
|
case "OpportunityEvents": {
|
|
const val = (<OpportunityEventsC2SEvent>event).Value
|
|
const opportunities = userData.Extensions.opportunityprogression
|
|
|
|
if (val.Event === "Completed") {
|
|
opportunities[val.RepositoryId] = true
|
|
}
|
|
|
|
writeUserData(req.jwt.unique_name, req.gameVersion)
|
|
break
|
|
}
|
|
case "AreaDiscovered":
|
|
// This might be an evergreen session,
|
|
// so we need to manually call challengeOnEvent for the area
|
|
// discovery challenge because onContractEvent won't do it for us
|
|
|
|
if (session.evergreen) {
|
|
const areaId = (<AreaDiscoveredC2SEvent>event).Value
|
|
.RepositoryId
|
|
|
|
const challengeId = getConfig("AreaMap", false)[areaId]
|
|
const progress = userData.Extensions.ChallengeProgression
|
|
|
|
log(LogLevel.DEBUG, `Area discovered: ${areaId}`)
|
|
|
|
// Nullability checks
|
|
progress[challengeId] ??= {
|
|
CurrentState: "Start",
|
|
Ticked: false,
|
|
Completed: false,
|
|
State: {
|
|
AreaIDs: [],
|
|
},
|
|
}
|
|
progress[challengeId].State ??= { AreaIDs: [] }
|
|
progress[challengeId].State.AreaIDs ??= []
|
|
|
|
controller.challengeService.challengeOnEvent(
|
|
event,
|
|
session,
|
|
challengeId,
|
|
userData,
|
|
{
|
|
context: progress[challengeId].State,
|
|
state: "Start",
|
|
timers: [],
|
|
timesCompleted: 0,
|
|
},
|
|
)
|
|
}
|
|
|
|
break
|
|
// Evergreen
|
|
case "CpdSet":
|
|
setCpd(
|
|
event.Value as ContractProgressionData,
|
|
userId,
|
|
contract.Metadata.CpdId,
|
|
)
|
|
break
|
|
case "Evergreen_Payout_Data":
|
|
session.evergreen.payout = (<Evergreen_Payout_DataC2SEvent>(
|
|
event
|
|
)).Value.Total_Payout
|
|
break
|
|
case "MissionFailed_Event":
|
|
if (session.evergreen) {
|
|
session.evergreen.failed = true
|
|
}
|
|
|
|
break
|
|
// Sinkhole events we don't care about
|
|
case "ItemPickedUp":
|
|
log(
|
|
LogLevel.INFO,
|
|
`Picked up item with repository ID: ${
|
|
(<ItemPickedUpC2SEvent>event).Value.RepositoryId
|
|
}`,
|
|
)
|
|
break
|
|
case "setpieces":
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Setpiece: ${
|
|
(<SetpiecesC2SEvent>event).Value.RepositoryId
|
|
}`,
|
|
)
|
|
break
|
|
case "ItemDropped":
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Item dropped: ${
|
|
(<ItemDroppedC2SEvent>event).Value.RepositoryId
|
|
}`,
|
|
)
|
|
break
|
|
case "AmbientChanged":
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Ambient switched to ${
|
|
(<AmbientChangedC2SEvent>event).Value.AmbientValue
|
|
}`,
|
|
)
|
|
break
|
|
case "Hero_Health":
|
|
case "NPC_Distracted":
|
|
case "ShotsHit":
|
|
case "FirstNonHeadshot":
|
|
case "FirstMissedShot":
|
|
default:
|
|
// no-op on our part
|
|
break
|
|
}
|
|
|
|
processed.push(event.Name)
|
|
|
|
response.push(process.hrtime.bigint().toString())
|
|
})
|
|
|
|
if (PEACOCK_DEV && processed.length > 0) {
|
|
log(
|
|
LogLevel.DEBUG,
|
|
`Event summary: ${picocolors.gray(processed.join(", "))}`,
|
|
)
|
|
}
|
|
|
|
return response
|
|
}
|
|
|
|
export { eventRouter }
|