import { PlayerDropEvent } from "@modules/FxPlayerlist"; import { PDL_CRASH_REASON_CHAR_LIMIT, PDL_UNKNOWN_REASON_CHAR_LIMIT } from "./config"; const playerInitiatedRules = [ `exiting`,//basically only see this one `disconnected.`, //need to keep the dot `connecting to another server`, `could not find requested level`, `entering rockstar editor`, `quit:`, `reconnecting`, `reloading game`, ]; //FIXME: remover essa categoria, checar com XXXX se os security podem vir como security ao invés de server-initiated const serverInitiatedRules = [ //NOTE: this is not a real drop reason prefix, but something that the client sees only `disconnected by server:`, //NOTE: Happens only when doing "quit xxxxxx" in live console `server shutting down:`, //NOTE: Happens when txAdmin players - but soon specific player kicks (instead of kick all) // will not fall under this category anymore `[txadmin]`, ]; const timeoutRules = [ `server->client connection timed out`, //basically only see this one - FIXME: falta espaço? `connection timed out`, `timed out after 60 seconds`, //onesync timeout ]; const securityRules = [ `reliable network event overflow`, `reliable network event size overflow:`, `reliable server command overflow`, `reliable state bag packet overflow`, `unreliable network event overflow`, `connection to cnl timed out`, `server command overflow`, `invalid client configuration. restart your game and reconnect`, ]; //The crash message prefix is translated, so we need to lookup the translations. //This list covers the top 20 languages used in FiveM, with exceptions languages with no translations. const crashRulesIntl = [ // (en) English - 18.46% `game crashed: `, // (pt) Portuguese - 15.67% `o jogo crashou: `, // (fr) French - 9.58% `le jeu a cessé de fonctionner : `, // (de) German - 9.15% `spielabsturz: `, // (es) Spanish - 6.08% `el juego crasheó: `, // (ar) Arabic - 6.04% `تعطلت العبة: `, // (nl) Dutch - 2.46% `spel werkt niet meer: `, // (tr) Turkish - 1.37% `oyun çöktü: `, // (hu) Hungarian - 1.31% `a játék összeomlott: `, // (it) Italian - 1.20% `il gioco ha smesso di funzionare: `, // (zh) Chinese - 1.02% `游戏发生崩溃:`, `遊戲已崩潰: `, // (cs) Czech - 0.92% `pád hry: `, // (sv) Swedish - 0.69% `spelet kraschade: `, ]; const exceptionPrefixesIntl = [ // (en) English - 18.46% `unhandled exception: `, // (pt) Portuguese - 15.67% `exceção não tratada: `, // (fr) French - 9.58% `exception non-gérée : `, // (de) German - 9.15% `unbehandelte ausnahme: `, // (es) Spanish - 6.08% `excepción no manejada: `, // (ar) Arabic - 6.04% `استثناء غير معالج: `, // (nl) Dutch - 2.46% // NOTE: Dutch doesn't have a translation for "unhandled exception" // (tr) Turkish - 1.37% `i̇şlenemeyen özel durum: `, // (hu) Hungarian - 1.31% `nem kezelt kivétel: `, // (it) Italian - 1.20% `eccezione non gestita: `, // (zh) Chinese - 1.02% `未处理的异常:`, `未處理的異常: `, // (cs) Czech - 0.92% `neošetřená výjimka: `, // (sv) Swedish - 0.69% `okänt fel: `, ]; const truncateReason = (reason: string, maxLength: number, prefix?: string) => { prefix = prefix ? `${prefix} ` : ''; if (prefix) { maxLength -= prefix.length; } const truncationSuffix = '[truncated]'; if (!reason.length) { return prefix + '[tx:empty-reason]'; }else if (reason.length > maxLength) { return prefix + reason.slice(0, maxLength - truncationSuffix.length) + truncationSuffix; } else { return prefix + reason; } } const cleanCrashReason = (reason: string) => { const cutoffIdx = reason.indexOf(': ') + 2; const msg = reason.slice(cutoffIdx); const exceptionPrefix = exceptionPrefixesIntl.find((prefix) => msg.toLocaleLowerCase().startsWith(prefix)); const saveMsg = exceptionPrefix ? 'Unhandled exception: ' + msg.slice(exceptionPrefix.length) : msg; return truncateReason(saveMsg, PDL_CRASH_REASON_CHAR_LIMIT); } /** * Classifies a drop reason into a category, and returns a cleaned up version of it. * The cleaned up version is truncated to a certain length. * The cleaned up version of crash reasons have the prefix translated back into English. */ const guessDropReasonCategory = (reason: string): ClassifyDropReasonResponse => { if (typeof reason !== 'string') { return { category: 'unknown' as const, cleanReason: '[tx:invalid-reason]', }; } const reasonToMatch = reason.trim().toLocaleLowerCase(); if (!reasonToMatch.length) { return { category: 'unknown' as const, cleanReason: '[tx:empty-reason]', }; } else if (playerInitiatedRules.some((rule) => reasonToMatch.startsWith(rule))) { return { category: 'player' }; } else if (serverInitiatedRules.some((rule) => reasonToMatch.startsWith(rule))) { return { category: false }; } else if (timeoutRules.some((rule) => reasonToMatch.includes(rule))) { return { category: 'timeout' }; } else if (securityRules.some((rule) => reasonToMatch.includes(rule))) { return { category: 'security' }; } else if (crashRulesIntl.some((rule) => reasonToMatch.includes(rule))) { return { category: 'crash' as const, cleanReason: cleanCrashReason(reason), }; } else { return { category: 'unknown' as const, cleanReason: truncateReason(reason, PDL_UNKNOWN_REASON_CHAR_LIMIT), }; } } //NOTE: From fivem/code/components/citizen-server-impl/include/ClientDropReasons.h export enum FxsDropReasonGroups { //1 resource dropped the client RESOURCE = 1, //2 client initiated a disconnect CLIENT, //3 server initiated a disconnect SERVER, //4 client with same guid connected and kicks old client CLIENT_REPLACED, //5 server -> client connection timed out CLIENT_CONNECTION_TIMED_OUT, //6 server -> client connection timed out with pending commands CLIENT_CONNECTION_TIMED_OUT_WITH_PENDING_COMMANDS, //7 server shutdown triggered the client drop SERVER_SHUTDOWN, //8 state bag rate limit exceeded STATE_BAG_RATE_LIMIT, //9 net event rate limit exceeded NET_EVENT_RATE_LIMIT, //10 latent net event rate limit exceeded LATENT_NET_EVENT_RATE_LIMIT, //11 command rate limit exceeded COMMAND_RATE_LIMIT, //12 too many missed frames in OneSync ONE_SYNC_TOO_MANY_MISSED_FRAMES, }; const timeoutCategory = [ FxsDropReasonGroups.CLIENT_CONNECTION_TIMED_OUT, FxsDropReasonGroups.CLIENT_CONNECTION_TIMED_OUT_WITH_PENDING_COMMANDS, FxsDropReasonGroups.ONE_SYNC_TOO_MANY_MISSED_FRAMES, ]; const securityCategory = [ FxsDropReasonGroups.SERVER, FxsDropReasonGroups.CLIENT_REPLACED, FxsDropReasonGroups.STATE_BAG_RATE_LIMIT, FxsDropReasonGroups.NET_EVENT_RATE_LIMIT, FxsDropReasonGroups.LATENT_NET_EVENT_RATE_LIMIT, FxsDropReasonGroups.COMMAND_RATE_LIMIT, ]; /** * Classifies a drop reason into a category, and returns a cleaned up version of it. * The cleaned up version is truncated to a certain length. * The cleaned up version of crash reasons have the prefix translated back into English. */ export const classifyDrop = (payload: PlayerDropEvent): ClassifyDropReasonResponse => { if (typeof payload.reason !== 'string') { return { category: 'unknown', cleanReason: '[tx:invalid-reason]', }; } else if (payload.category === undefined || payload.resource === undefined) { return guessDropReasonCategory(payload.reason); } if (typeof payload.category !== 'number' || payload.category <= 0) { return { category: 'unknown', cleanReason: truncateReason( payload.reason, PDL_UNKNOWN_REASON_CHAR_LIMIT, '[tx:invalid-category]' ), }; } else if (payload.category === FxsDropReasonGroups.RESOURCE) { if (payload.resource === 'monitor') { //if server shutting down, return ignore, otherwise return server-initiated if (payload.reason === 'server_shutting_down') { return { category: false }; } else { return { category: 'resource', resource: 'txAdmin' }; } } else { return { category: 'resource', resource: payload.resource ? payload.resource : 'unknown', } } } else if (payload.category === FxsDropReasonGroups.CLIENT) { //check if it's crash const reasonToMatch = payload.reason.trim().toLocaleLowerCase(); if (crashRulesIntl.some((rule) => reasonToMatch.includes(rule))) { return { category: 'crash', cleanReason: cleanCrashReason(payload.reason), } } else { return { category: 'player' }; } } else if (timeoutCategory.includes(payload.category)) { return { category: 'timeout' }; } else if (securityCategory.includes(payload.category)) { return { category: 'security' }; } else if (payload.category === FxsDropReasonGroups.SERVER_SHUTDOWN) { return { category: false }; } else { return { category: 'unknown', cleanReason: truncateReason( payload.reason, PDL_UNKNOWN_REASON_CHAR_LIMIT, '[tx:unknown-category]' ), }; } } type SimpleDropCategory = 'player' | 'timeout' | 'security'; // type DetailedDropCategory = 'resource' | 'crash' | 'unknown'; // type DropCategories = SimpleDropCategory | DetailedDropCategory; type ClassifyDropReasonResponse = { category: SimpleDropCategory; } | { category: 'resource'; resource: string; } | { category: 'crash' | 'unknown'; cleanReason: string; } | { category: false; //server shutting down, ignore }