const modulename = 'PlayerDropMetrics'; import fsp from 'node:fs/promises'; import consoleFactory from '@lib/console'; import { PDLChangeEventType, PDLFileSchema, PDLFileType, PDLHourlyRawType, PDLHourlyType, PDLServerBootDataSchema } from './playerDropSchemas'; import { classifyDrop } from './classifyDropReason'; import { PDL_RETENTION, PDL_UNKNOWN_LIST_SIZE_LIMIT } from './config'; import { ZodError } from 'zod'; import { getDateHourEnc, parseDateHourEnc } from './playerDropUtils'; import { MultipleCounter } from '../statsUtils'; import { throttle } from 'throttle-debounce'; import { PlayerDropsDetailedWindow, PlayerDropsSummaryHour } from '@routes/playerDrops'; import { migratePlayerDropsFile } from './playerDropMigrations'; import { parseFxserverVersion } from '@lib/fxserver/fxsVersionParser'; import { PlayerDropEvent } from '@modules/FxPlayerlist'; import { txEnv } from '@core/globalData'; const console = consoleFactory(modulename); //Consts export const LOG_DATA_FILE_VERSION = 2; const LOG_DATA_FILE_NAME = 'stats_playerDrop.json'; /** * Stores player drop logs, and also logs other information that might be relevant to player crashes, * such as changes to the detected game/server version, resources, etc. * * NOTE: PDL = PlayerDropLog */ export default class PlayerDropMetrics { private readonly logFilePath = `${txEnv.profilePath}/data/${LOG_DATA_FILE_NAME}`; private eventLog: PDLHourlyType[] = []; private lastGameVersion: string | undefined; private lastServerVersion: string | undefined; private lastResourceList: string[] | undefined; private lastUnknownReasons: string[] = []; private queueSaveEventLog = throttle( 15_000, this.saveEventLog.bind(this), { noLeading: true } ); constructor() { setImmediate(() => { this.loadEventLog(); }); } /** * Get the recent category count for player drops in the last X hours */ public getRecentDropTally(windowHours: number) { const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000) - 1; const flatCounts = this.eventLog .filter((entry) => entry.hour.dateHourTs >= logCutoff) .map((entry) => entry.dropTypes.toSortedValuesArray()) .flat(); const cumulativeCounter = new MultipleCounter(); cumulativeCounter.merge(flatCounts); return cumulativeCounter.toSortedValuesArray(); } /** * Get the recent log with drop/crash/changes for the last X hours */ public getRecentSummary(windowHours: number): PlayerDropsSummaryHour[] { const logCutoff = (new Date).setUTCMinutes(0, 0, 0) - (windowHours * 60 * 60 * 1000); const windowSummary = this.eventLog .filter((entry) => entry.hour.dateHourTs >= logCutoff) .map((entry) => ({ hour: entry.hour.dateHourStr, changes: entry.changes.length, dropTypes: entry.dropTypes.toSortedValuesArray(), })); return windowSummary; } /** * Get the data for the player drops drilldown card within a inclusive time window */ public getWindowData(windowStart: number, windowEnd: number): PlayerDropsDetailedWindow { const allChanges: PDLChangeEventType[] = []; const crashTypes = new MultipleCounter(); const dropTypes = new MultipleCounter(); const resKicks = new MultipleCounter(); const filteredLogs = this.eventLog.filter((entry) => { return entry.hour.dateHourTs >= windowStart && entry.hour.dateHourTs <= windowEnd; }); for (const log of filteredLogs) { allChanges.push(...log.changes); crashTypes.merge(log.crashTypes); dropTypes.merge(log.dropTypes); resKicks.merge(log.resKicks); } return { changes: allChanges, crashTypes: crashTypes.toSortedValuesArray(true), dropTypes: dropTypes.toSortedValuesArray(true), resKicks: resKicks.toSortedValuesArray(true), }; } /** * Returns the object of the current hour object in log. * Creates one if doesn't exist one for the current hour. */ private getCurrentLogHourRef() { const { dateHourTs, dateHourStr } = getDateHourEnc(); const currentHourLog = this.eventLog.find((entry) => entry.hour.dateHourStr === dateHourStr); if (currentHourLog) return currentHourLog; const newHourLog: PDLHourlyType = { hour: { dateHourTs: dateHourTs, dateHourStr: dateHourStr, }, changes: [], crashTypes: new MultipleCounter(), dropTypes: new MultipleCounter(), resKicks: new MultipleCounter(), }; this.eventLog.push(newHourLog); return newHourLog; } /** * Handles receiving the data sent to the logger as soon as the server boots */ public handleServerBootData(rawPayload: any) { const logRef = this.getCurrentLogHourRef(); //Parsing data const validation = PDLServerBootDataSchema.safeParse(rawPayload); if (!validation.success) { console.warn(`Invalid server boot data: ${validation.error.errors}`); return; } const { gameName, gameBuild, fxsVersion, resources } = validation.data; let shouldSave = false; //Game version change const gameString = `${gameName}:${gameBuild}`; if (gameString) { if (!this.lastGameVersion) { shouldSave = true; } else if (gameString !== this.lastGameVersion) { shouldSave = true; logRef.changes.push({ ts: Date.now(), type: 'gameChanged', oldVersion: this.lastGameVersion, newVersion: gameString, }); } this.lastGameVersion = gameString; } //Server version change let { build: serverBuild, platform: serverPlatform } = parseFxserverVersion(fxsVersion); const fxsVersionString = `${serverPlatform}:${serverBuild}`; if (fxsVersionString) { if (!this.lastServerVersion) { shouldSave = true; } else if (fxsVersionString !== this.lastServerVersion) { shouldSave = true; logRef.changes.push({ ts: Date.now(), type: 'fxsChanged', oldVersion: this.lastServerVersion, newVersion: fxsVersionString, }); } this.lastServerVersion = fxsVersionString; } //Resource list change - if no resources, ignore as that's impossible if (resources.length) { if (!this.lastResourceList || !this.lastResourceList.length) { shouldSave = true; } else { const resAdded = resources.filter(r => !this.lastResourceList!.includes(r)); const resRemoved = this.lastResourceList.filter(r => !resources.includes(r)); if (resAdded.length || resRemoved.length) { shouldSave = true; logRef.changes.push({ ts: Date.now(), type: 'resourcesChanged', resAdded, resRemoved, }); } } this.lastResourceList = resources; } //Saving if needed if (shouldSave) { this.queueSaveEventLog(); } } /** * Handles receiving the player drop event, and returns the category of the drop */ public handlePlayerDrop(event: PlayerDropEvent) { const drop = classifyDrop(event); //Ignore server shutdown drops if (drop.category === false) return false; //Log the drop const logRef = this.getCurrentLogHourRef(); logRef.dropTypes.count(drop.category); if (drop.category === 'resource' && drop.resource) { logRef.resKicks.count(drop.resource); } else if (drop.category === 'crash' && drop.cleanReason) { logRef.crashTypes.count(drop.cleanReason); } else if (drop.category === 'unknown' && drop.cleanReason) { if (!this.lastUnknownReasons.includes(drop.cleanReason)) { this.lastUnknownReasons.push(drop.cleanReason); } } this.queueSaveEventLog(); return drop.category; } /** * Resets the player drop stats log */ public resetLog(reason: string) { if (typeof reason !== 'string' || !reason) throw new Error(`reason required`); this.eventLog = []; this.lastGameVersion = undefined; this.lastServerVersion = undefined; this.lastResourceList = undefined; this.lastUnknownReasons = []; this.queueSaveEventLog.cancel({ upcomingOnly: true }); this.saveEventLog(reason); } /** * Loads the stats database/cache/history */ private async loadEventLog() { try { const rawFileData = await fsp.readFile(this.logFilePath, 'utf8'); const fileData = JSON.parse(rawFileData); let statsData: PDLFileType; if (fileData.version === LOG_DATA_FILE_VERSION) { statsData = PDLFileSchema.parse(fileData); } else { try { statsData = await migratePlayerDropsFile(fileData); } catch (error) { throw new Error(`Failed to migrate ${LOG_DATA_FILE_NAME} from ${fileData?.version} to ${LOG_DATA_FILE_VERSION}: ${(error as Error).message}`); } } this.lastGameVersion = statsData.lastGameVersion; this.lastServerVersion = statsData.lastServerVersion; this.lastResourceList = statsData.lastResourceList; this.lastUnknownReasons = statsData.lastUnknownReasons; this.eventLog = statsData.log.map((entry): PDLHourlyType => { return { hour: parseDateHourEnc(entry.hour), changes: entry.changes, crashTypes: new MultipleCounter(entry.crashTypes), dropTypes: new MultipleCounter(entry.dropTypes), resKicks: new MultipleCounter(entry.resKicks), } }); console.verbose.ok(`Loaded ${this.eventLog.length} log entries from cache`); this.optimizeStatsLog(); } catch (error) { if ((error as any)?.code === 'ENOENT') { console.verbose.debug(`${LOG_DATA_FILE_NAME} not found, starting with empty stats.`); this.resetLog('File was just created, no data yet'); return; } if (error instanceof ZodError) { console.warn(`Failed to load ${LOG_DATA_FILE_NAME} due to invalid data.`); this.resetLog('Failed to load log file due to invalid data'); } else { console.warn(`Failed to load ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`); this.resetLog('Failed to load log file due to unknown error'); } console.warn('Since this is not a critical file, it will be reset.'); } } /** * Optimizes the event log by removing old entries */ private optimizeStatsLog() { if (this.lastUnknownReasons.length > PDL_UNKNOWN_LIST_SIZE_LIMIT) { this.lastUnknownReasons = this.lastUnknownReasons.slice(-PDL_UNKNOWN_LIST_SIZE_LIMIT); } const maxAge = Date.now() - PDL_RETENTION; const cutoffIdx = this.eventLog.findIndex((entry) => entry.hour.dateHourTs > maxAge); if (cutoffIdx > 0) { this.eventLog = this.eventLog.slice(cutoffIdx); } } /** * Saves the stats database/cache/history */ private async saveEventLog(emptyReason?: string) { try { const sizeBefore = this.eventLog.length; this.optimizeStatsLog(); if (!this.eventLog.length) { if (sizeBefore) { emptyReason ??= 'Cleared due to retention policy'; } } else { emptyReason = undefined; } const savePerfData: PDLFileType = { version: LOG_DATA_FILE_VERSION, emptyReason, lastGameVersion: this.lastGameVersion ?? 'unknown', lastServerVersion: this.lastServerVersion ?? 'unknown', lastResourceList: this.lastResourceList ?? [], lastUnknownReasons: this.lastUnknownReasons, log: this.eventLog.map((entry): PDLHourlyRawType => { return { hour: entry.hour.dateHourStr, changes: entry.changes, crashTypes: entry.crashTypes.toArray(), dropTypes: entry.dropTypes.toArray(), resKicks: entry.resKicks.toArray(), } }), }; await fsp.writeFile(this.logFilePath, JSON.stringify(savePerfData)); } catch (error) { console.warn(`Failed to save ${LOG_DATA_FILE_NAME} with message: ${(error as Error).message}`); } } };