352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
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}`);
|
|
}
|
|
}
|
|
};
|