1
mirror of https://github.com/thepeacockproject/Peacock synced 2025-02-23 03:35:25 +01:00

New saving/loading behaviors to fix bugs related to saving ()

Signed-off-by: moonysolari <118079569+moonysolari@users.noreply.github.com>
This commit is contained in:
moonysolari 2023-01-09 14:49:04 -05:00 committed by GitHub
parent d95553bd33
commit e548325955
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 220 additions and 98 deletions

@ -23,6 +23,7 @@ import { serializeSession, deserializeSession } from "./sessionSerialization"
import { castUserProfile } from "./utils"
import { getConfig } from "./configSwizzleManager"
import { log, LogLevel } from "./loggingInterop"
import { unlink, readdir } from "fs/promises"
/**
* Container for functions that handle file read/writes,
@ -216,17 +217,24 @@ export async function writeExternalUserData(
/**
* Reads a contract session from the contractSessions folder.
*
* @param sessionId The ID of the session to load.
* @param identifier The identifier for the saved session, in the format of token_sessionID.
* @returns The contract session.
*/
export async function getContractSession(
sessionId: string,
identifier: string,
): Promise<ContractSession> {
const files = await readdir("contractSessions")
const filtered = files.filter((fn) => fn.endsWith(`_${identifier}.json`))
if (filtered.length === 0) {
throw new Error(`No session saved with identifier ${identifier}`)
}
// The filtered files have the same identifier, they are just stored at different slots
// So we can read any of them and it will be the same.
return deserializeSession(
JSON.parse(
(
await readFile(join("contractSessions", `${sessionId}.json`))
).toString(),
(await readFile(join("contractSessions", filtered[0]))).toString(),
),
)
}
@ -234,15 +242,25 @@ export async function getContractSession(
/**
* Writes a contract session to the contractsSessions folder.
*
* @param sessionId The session's ID.
* @param identifier The identifier for the saved session, in the format of slot_token_sessionID.
* @param session The contract session.
*/
export async function writeContractSession(
sessionId: string,
identifier: string,
session: ContractSession,
): Promise<void> {
return await writeFile(
join("contractSessions", `${sessionId}.json`),
join("contractSessions", `${identifier}.json`),
JSON.stringify(serializeSession(session)),
)
}
/**
* Deletes a saved contract session from the contractsSessions folder.
*
* @param fileName The identifier for the saved session, in the format of slot_token_sessionID.
* @throws ENOENT if the file is not found.
*/
export async function deleteContractSession(fileName: string): Promise<void> {
return await unlink(join("contractSessions", `${fileName}.json`))
}

@ -33,19 +33,13 @@ import {
import { extractToken, ServerVer } from "./utils"
import { json as jsonMiddleware } from "body-parser"
import { log, LogLevel } from "./loggingInterop"
import {
getContractSession,
getUserData,
writeContractSession,
writeUserData,
} from "./databaseHandler"
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 picocolors from "picocolors"
import { encodePushMessage } from "./multiplayer/multiplayerUtils"
import {
ActorTaggedC2SEvent,
@ -65,6 +59,7 @@ import {
SpottedC2SEvent,
WitnessesC2SEvent,
} from "./types/events"
import picocolors from "picocolors"
const eventRouter = Router()
@ -682,14 +677,20 @@ function saveEvents(
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":
@ -799,31 +800,4 @@ function saveEvents(
return response
}
export async function saveSession(
sessionId: string,
token: string,
): Promise<void> {
if (!contractSessions.has(sessionId)) {
log(LogLevel.WARN, `Refusing to save ${sessionId} as it doesn't exist`)
return
}
await writeContractSession(
token + "_" + sessionId,
contractSessions.get(sessionId)!,
)
}
export async function loadSession(
sessionId: string,
token: string,
sessionData?: ContractSession,
): Promise<void> {
if (!sessionData) {
sessionData = await getContractSession(token + "_" + sessionId)
}
contractSessions.set(sessionId, sessionData)
}
export { eventRouter }

@ -21,20 +21,24 @@ import path from "path"
import { castUserProfile, nilUuid, uuidRegex } from "./utils"
import { json as jsonMiddleware } from "body-parser"
import { getPlatformEntitlements } from "./platformEntitlements"
import {
getActiveSessionIdForUser,
loadSession,
newSession,
saveSession,
} from "./eventHandler"
import { contractSessions, newSession } from "./eventHandler"
import type {
CompiledChallengeRuntimeData,
ContractSession,
GameVersion,
RequestWithJwt,
SaveFile,
UpdateUserSaveFileTableBody,
UserProfile,
} from "./types/types"
import { log, LogLevel } from "./loggingInterop"
import { getUserData, writeUserData } from "./databaseHandler"
import {
deleteContractSession,
getContractSession,
getUserData,
writeContractSession,
writeUserData,
} from "./databaseHandler"
import { randomUUID } from "crypto"
import { getVersionedConfig } from "./configSwizzleManager"
import { createInventory } from "./inventory"
@ -630,26 +634,38 @@ profileRouter.post(
profileRouter.post(
"/ProfileService/UpdateUserSaveFileTable",
jsonMiddleware(),
async (req, res) => {
async (req: RequestWithJwt<never, UpdateUserSaveFileTableBody>, res) => {
if (req.body.clientSaveFileList.length > 0) {
const save =
req.body.clientSaveFileList[
req.body.clientSaveFileList.length - 1
]
// We are saving to the SaveFile with the most recent timestamp.
// Others are ignored.
const save: SaveFile = req.body.clientSaveFileList.reduce(
(prev: SaveFile, current: SaveFile) =>
prev.TimeStamp > current.TimeStamp ? prev : current,
)
const userData = getUserData(req.jwt.unique_name, req.gameVersion)
try {
await saveSession(
save.ContractSessionId,
save.Value.LastEventToken,
)
await saveSession(save, userData)
// Successfully saved, so edit user data
if (!userData.Extensions.Saves) {
userData.Extensions.Saves = {}
}
userData.Extensions.Saves[save.Value.Name] = {
Timestamp: save.TimeStamp,
ContractSessionId: save.ContractSessionId,
Token: save.Value.LastEventToken,
}
writeUserData(req.jwt.unique_name, req.gameVersion)
} catch (e) {
log(
LogLevel.WARN,
`Unable to save session ${save?.ContractSessionId}`,
)
if (PEACOCK_DEV) {
log(LogLevel.DEBUG, e.name)
if (getErrorCause(e) === "cause uninvestigated") {
log(LogLevel.DEBUG, `${getErrorMessage(e)}`)
} else {
log(
LogLevel.WARN,
`Unable to save session ${
save?.ContractSessionId
} because ${getErrorMessage(e)}.`,
)
}
}
}
@ -658,6 +674,75 @@ profileRouter.post(
},
)
function getErrorMessage(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
}
function getErrorCause(error: unknown) {
if (error instanceof Error) return error.cause
return String(error)
}
async function saveSession(
save: SaveFile,
userData: UserProfile,
): Promise<void> {
const sessionId = save.ContractSessionId
const token = save.Value.LastEventToken
const slot = save.Value.Name
if (!contractSessions.has(sessionId)) {
throw new Error("the session does not exist in the server's memory", {
cause: "non-existent",
})
}
if (!userData.Extensions.Saves) {
userData.Extensions.Saves = {}
}
if (slot in userData.Extensions.Saves) {
const delta = save.TimeStamp - userData.Extensions.Saves[slot].Timestamp
if (delta === 0) {
throw new Error(
`the client is accessing /ProfileService/UpdateUserSaveFileTable with nothing updated.`,
{ cause: "cause uninvestigated" },
)
} else if (delta < 0) {
throw new Error(`there is a newer save in slot ${slot}`, {
cause: "outdated",
})
} else {
// If we can delete the old save, then do it. If not, we can still proceed.
try {
await deleteContractSession(
slot +
"_" +
userData.Extensions.Saves[slot].Token +
"_" +
userData.Extensions.Saves[slot].ContractSessionId,
)
} catch (e) {
log(
LogLevel.DEBUG,
`Failed to delete old ${slot} save. ${getErrorMessage(e)}.`,
)
}
}
}
await writeContractSession(
slot + "_" + token + "_" + sessionId,
contractSessions.get(sessionId)!,
)
log(
LogLevel.DEBUG,
`Saved contract to slot ${slot} with token = ${token}, session id = ${sessionId}, start time = ${
contractSessions.get(sessionId).timerStart
}.`,
)
}
profileRouter.post(
"/ContractSessionsService/Load",
jsonMiddleware(),
@ -674,47 +759,59 @@ profileRouter.post(
try {
await loadSession(req.body.contractSessionId, req.body.saveToken)
} catch (e) {
if (
getActiveSessionIdForUser(req.jwt.unique_name) ===
req.body.contractSessionId
) {
log(
LogLevel.INFO,
"Tried to load the active session, prevented to avoid crash.",
)
} else {
log(
LogLevel.WARN,
"No such save detected! Might be an official servers save.",
)
if (PEACOCK_DEV) {
log(
LogLevel.DEBUG,
`(Save-context: ${req.body.contractSessionId}; ${req.body.saveToken})`,
)
}
log(
LogLevel.DEBUG,
`Failed to load contract with token = ${req.body.saveToken}, session id = ${req.body.contractSessionId} because ${e.message}`,
)
log(
LogLevel.WARN,
"No such save detected! Might be an official servers save.",
)
if (PEACOCK_DEV) {
log(
LogLevel.WARN,
"Creating a fake session to avoid problems... scoring will not work!",
)
newSession(
req.body.contractSessionId,
req.body.contractId,
req.jwt.unique_name,
req.body.difficultyLevel!,
req.gameVersion,
false,
LogLevel.DEBUG,
`(Save-context: ${req.body.contractSessionId}; ${req.body.saveToken})`,
)
}
log(
LogLevel.WARN,
"Creating a fake session to avoid problems... scoring will not work!",
)
newSession(
req.body.contractSessionId,
req.body.contractId,
req.jwt.unique_name,
req.body.difficultyLevel!,
req.gameVersion,
false,
)
}
res.send(`"${req.body.contractSessionId}"`)
},
)
async function loadSession(
sessionId: string,
token: string,
sessionData?: ContractSession,
): Promise<void> {
if (!sessionData) {
sessionData = await getContractSession(token + "_" + sessionId)
}
contractSessions.set(sessionId, sessionData)
log(
LogLevel.DEBUG,
`Loaded contract with token = ${token}, session id = ${sessionId}, start time = ${
contractSessions.get(sessionId).timerStart
}.`,
)
}
profileRouter.post(
"/ProfileService/GetSemLinkStatus",
jsonMiddleware(),

@ -250,6 +250,32 @@ export interface ContractSession {
}
}
/**
* The SaveFile object passed by the client in /ProfileService/UpdateUserSaveFileTable
*/
export interface SaveFile {
// The contract session ID of the save
ContractSessionId: string
// The unix timestamp at the time of saving
TimeStamp: number
Value: {
// The name of the save slot
Name: string
// The token of the last event that happened before the save was made
LastEventToken: string
}
}
/**
* The body sent with the UpdateUserSaveFileTable request from the game after saving.
*
* @see SaveFile
*/
export interface UpdateUserSaveFileTableBody {
clientSaveFileList: SaveFile[]
deletedSaveFileList: SaveFile[]
}
/**
* The Hitman server version in object form.
*/
@ -340,6 +366,13 @@ export interface UserProfile {
}
PeacockFavoriteContracts: string[]
PeacockCompletedEscalations: string[]
Saves: {
[slot: string]: {
Timestamp: number
ContractSessionId: string
Token: string
}
}
ChallengeProgression: {
[id: string]: ProfileChallengeData
}

@ -5,7 +5,7 @@
"PeacockEscalations": {},
"PeacockCompletedEscalations": [],
"PeacockFavoriteContracts": [],
"PeacockCChild": {},
"Saves": {},
"gameclient": {
"LastConnectionVersion": {
"_Major": 6,

@ -5,7 +5,7 @@
"PeacockEscalations": {},
"PeacockFavoriteContracts": [],
"PeacockCompletedEscalations": [],
"PeacockCChild": {},
"Saves": {},
"gameclient": null,
"progression": {
"XPGain": 0,