366 lines
13 KiB
TypeScript
366 lines
13 KiB
TypeScript
const modulename = 'Player';
|
|
import cleanPlayerName from '@shared/cleanPlayerName';
|
|
import { DatabaseActionWarnType, DatabasePlayerType, DatabaseWhitelistApprovalsType } from '@modules/Database/databaseTypes';
|
|
import { cloneDeep, union } from 'lodash-es';
|
|
import { now } from '@lib/misc';
|
|
import { parsePlayerIds } from '@lib/player/idUtils';
|
|
import consoleFactory from '@lib/console';
|
|
import consts from '@shared/consts';
|
|
import type FxPlayerlist from '@modules/FxPlayerlist';
|
|
const console = consoleFactory(modulename);
|
|
|
|
|
|
/**
|
|
* Base class for ServerPlayer and DatabasePlayer.
|
|
* NOTE: player classes are responsible to every and only business logic regarding the player object in the database.
|
|
* In the future, when actions become part of the player object, also add them to these classes.
|
|
*/
|
|
export class BasePlayer {
|
|
displayName: string = 'unknown';
|
|
pureName: string = 'unknown';
|
|
ids: string[] = [];
|
|
hwids: string[] = [];
|
|
license: null | string = null; //extracted for convenience
|
|
dbData: false | DatabasePlayerType = false;
|
|
isConnected: boolean = false;
|
|
|
|
constructor(readonly uniqueId: Symbol) { }
|
|
|
|
/**
|
|
* Mutates the database data based on a source object to be applied
|
|
* FIXME: if this is called for a disconnected ServerPlayer, it will not clean after 120s
|
|
*/
|
|
protected mutateDbData(srcData: object) {
|
|
if (!this.license) throw new Error(`cannot mutate database for a player that has no license`);
|
|
this.dbData = txCore.database.players.update(this.license, srcData, this.uniqueId);
|
|
}
|
|
|
|
/**
|
|
* Returns all available identifiers (current+db)
|
|
*/
|
|
getAllIdentifiers() {
|
|
if (this.dbData && this.dbData.ids) {
|
|
return union(this.ids, this.dbData.ids);
|
|
} else {
|
|
return [...this.ids];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns all available hardware identifiers (current+db)
|
|
*/
|
|
getAllHardwareIdentifiers() {
|
|
if (this.dbData && this.dbData.hwids) {
|
|
return union(this.hwids, this.dbData.hwids);
|
|
} else {
|
|
return [...this.hwids];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns all actions related to all available ids
|
|
* NOTE: theoretically ServerPlayer.setupDatabaseData() guarantees that DatabasePlayer.dbData.ids array
|
|
* will contain the license but may be better to also explicitly add it to the array here?
|
|
*/
|
|
getHistory() {
|
|
if (!this.ids.length) return [];
|
|
return txCore.database.actions.findMany(
|
|
this.getAllIdentifiers(),
|
|
this.getAllHardwareIdentifiers()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Saves notes for this player.
|
|
* NOTE: Techinically, we should be checking this.isRegistered, but not available in BasePlayer
|
|
*/
|
|
setNote(text: string, author: string) {
|
|
if (!this.license) throw new Error(`cannot save notes for a player that has no license`);
|
|
this.mutateDbData({
|
|
notes: {
|
|
text,
|
|
lastAdmin: author,
|
|
tsLastEdit: now(),
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Saves the whitelist status for this player
|
|
* NOTE: Techinically, we should be checking this.isRegistered, but not available in BasePlayer
|
|
*/
|
|
setWhitelist(enabled: boolean) {
|
|
if (!this.license) throw new Error(`cannot set whitelist status for a player that has no license`);
|
|
this.mutateDbData({
|
|
tsWhitelisted: enabled ? now() : undefined,
|
|
});
|
|
|
|
//Remove entries from whitelistApprovals & whitelistRequests
|
|
const allIdsFilter = (x: DatabaseWhitelistApprovalsType) => {
|
|
return this.ids.includes(x.identifier);
|
|
}
|
|
txCore.database.whitelist.removeManyApprovals(allIdsFilter);
|
|
txCore.database.whitelist.removeManyRequests({ license: this.license });
|
|
}
|
|
}
|
|
|
|
|
|
type PlayerDataType = {
|
|
name: string,
|
|
ids: string[],
|
|
hwids: string[],
|
|
}
|
|
|
|
/**
|
|
* Class to represent a player that is or was connected to the currently running server process.
|
|
*/
|
|
export class ServerPlayer extends BasePlayer {
|
|
readonly #fxPlayerlist: FxPlayerlist;
|
|
// readonly psid: string; //TODO: calculate player session id (sv mutex, netid, rollover id) here
|
|
readonly netid: number;
|
|
readonly tsConnected = now();
|
|
readonly isRegistered: boolean;
|
|
readonly #minuteCronInterval?: ReturnType<typeof setInterval>;
|
|
// #offlineDbDataCacheTimeout?: ReturnType<typeof setTimeout>;
|
|
|
|
constructor(
|
|
netid: number,
|
|
playerData: PlayerDataType,
|
|
fxPlayerlist: FxPlayerlist
|
|
) {
|
|
super(Symbol(`netid${netid}`));
|
|
this.#fxPlayerlist = fxPlayerlist;
|
|
this.netid = netid;
|
|
this.isConnected = true;
|
|
if (
|
|
playerData === null
|
|
|| typeof playerData !== 'object'
|
|
|| typeof playerData.name !== 'string'
|
|
|| !Array.isArray(playerData.ids)
|
|
|| !Array.isArray(playerData.hwids)
|
|
) {
|
|
throw new Error(`invalid player data`);
|
|
}
|
|
|
|
//Processing identifiers
|
|
//NOTE: ignoring IP completely
|
|
const { validIdsArray, validIdsObject } = parsePlayerIds(playerData.ids);
|
|
this.license = validIdsObject.license;
|
|
this.ids = validIdsArray;
|
|
this.hwids = playerData.hwids.filter(x => {
|
|
return typeof x === 'string' && consts.regexValidHwidToken.test(x);
|
|
});
|
|
|
|
//Processing player name
|
|
const { displayName, pureName } = cleanPlayerName(playerData.name);
|
|
this.displayName = displayName;
|
|
this.pureName = pureName;
|
|
|
|
//If this player is eligible to be on the database
|
|
if (this.license) {
|
|
this.#setupDatabaseData();
|
|
this.isRegistered = !!this.dbData;
|
|
this.#minuteCronInterval = setInterval(this.#minuteCron.bind(this), 60_000);
|
|
} else {
|
|
this.isRegistered = false;
|
|
}
|
|
console.log(167, this.isRegistered)
|
|
}
|
|
|
|
|
|
/**
|
|
* Registers or retrieves the player data from the database.
|
|
* NOTE: if player has license, we are guaranteeing license will be added to the database ids array
|
|
*/
|
|
#setupDatabaseData() {
|
|
if (!this.license || !this.isConnected) return;
|
|
|
|
//Make sure the database is ready - this should be impossible
|
|
if (!txCore.database.isReady) {
|
|
console.error(`Players database not yet ready, cannot read db status for player id ${this.displayName}.`);
|
|
return;
|
|
}
|
|
|
|
//Check if player is already on the database
|
|
try {
|
|
const dbPlayer = txCore.database.players.findOne(this.license);
|
|
if (dbPlayer) {
|
|
//Updates database data
|
|
this.dbData = dbPlayer;
|
|
this.mutateDbData({
|
|
displayName: this.displayName,
|
|
pureName: this.pureName,
|
|
tsLastConnection: this.tsConnected,
|
|
ids: union(dbPlayer.ids, this.ids),
|
|
hwids: union(dbPlayer.hwids, this.hwids),
|
|
});
|
|
} else {
|
|
//Register player to the database
|
|
console.log(`Registering '${this.displayName}' to players database.`);
|
|
const toRegister = {
|
|
license: this.license,
|
|
ids: this.ids,
|
|
hwids: this.hwids,
|
|
displayName: this.displayName,
|
|
pureName: this.pureName,
|
|
playTime: 0,
|
|
tsLastConnection: this.tsConnected,
|
|
tsJoined: this.tsConnected,
|
|
};
|
|
txCore.database.players.register(toRegister);
|
|
this.dbData = toRegister;
|
|
console.verbose.ok(`Adding '${this.displayName}' to players database.`);
|
|
|
|
}
|
|
setImmediate(this.#sendInitialData.bind(this));
|
|
} catch (error) {
|
|
console.error(`Failed to load/register player ${this.displayName} from/to the database with error:`);
|
|
console.dir(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prepares the initial player data and reports to FxPlayerlist, which will dispatch to the server via command.
|
|
* TODO: adapt to be used for admin auth and player tags.
|
|
*/
|
|
#sendInitialData() {
|
|
if (!this.isRegistered) return;
|
|
if (!this.dbData) throw new Error(`cannot send initial data for a player that has no dbData`);
|
|
|
|
let oldestPendingWarn: undefined | DatabaseActionWarnType;
|
|
const actionHistory = this.getHistory();
|
|
for (const action of actionHistory) {
|
|
if (action.type !== 'warn' || action.revocation.timestamp !== null) continue;
|
|
if (!action.acked) {
|
|
oldestPendingWarn = action;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (oldestPendingWarn) {
|
|
this.#fxPlayerlist.dispatchInitialPlayerData(this.netid, oldestPendingWarn);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets the dbData.
|
|
* Used when some other player instance mutates the database and we need to sync all players
|
|
* with the same license.
|
|
*/
|
|
syncUpstreamDbData(srcData: DatabasePlayerType) {
|
|
this.dbData = cloneDeep(srcData)
|
|
}
|
|
|
|
/**
|
|
* Returns a clone of this.dbData.
|
|
* If the data is not available, it means the player was disconnected and dbData wiped to save memory,
|
|
* so start an 120s interval to wipe it from memory again. This period can be considered a "cache"
|
|
* FIXME: review dbData optimization, 50k players would be up to 50mb
|
|
*/
|
|
getDbData() {
|
|
if (this.dbData) {
|
|
return cloneDeep(this.dbData);
|
|
} else if (this.license && this.isRegistered) {
|
|
const dbPlayer = txCore.database.players.findOne(this.license);
|
|
if (!dbPlayer) return false;
|
|
|
|
this.dbData = dbPlayer;
|
|
// clearTimeout(this.#offlineDbDataCacheTimeout); //maybe not needed?
|
|
// this.#offlineDbDataCacheTimeout = setTimeout(() => {
|
|
// this.dbData = false;
|
|
// }, 120_000);
|
|
return cloneDeep(this.dbData);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Updates dbData play time every minute
|
|
*/
|
|
#minuteCron() {
|
|
//FIXME: maybe use UIntXarray or mnemonist.Uint16Vector circular buffers to save memory
|
|
//TODO: rough draft of a playtime tracking system written before note above
|
|
// let list: [day: string, mins: number][] = [];
|
|
// const today = new Date;
|
|
// const currDay = today.toISOString().split('T')[0];
|
|
// if(!list.length){
|
|
// list.push([currDay, 1]);
|
|
// return;
|
|
// }
|
|
// if(list.at(-1)![0] === currDay){
|
|
// list.at(-1)![1]++;
|
|
// } else {
|
|
// //FIXME: move this cutoff to a const in the database or playerlist manager
|
|
// const cutoffTs = today.setUTCHours(0, 0, 0, 0) - 1000 * 60 * 60 * 24 * 28;
|
|
// const cutoffIndex = list.findIndex(x => new Date(x[0]).getTime() < cutoffTs);
|
|
// list = list.slice(cutoffIndex);
|
|
// list.push([currDay, 1]);
|
|
// }
|
|
|
|
|
|
if (!this.dbData || !this.isConnected) return;
|
|
try {
|
|
this.mutateDbData({ playTime: this.dbData.playTime + 1 });
|
|
} catch (error) {
|
|
console.warn(`Failed to update playtime for player ${this.displayName}:`);
|
|
console.dir(error);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Marks this player as disconnected, clears dbData (mem optimization) and clears minute cron
|
|
*/
|
|
disconnect() {
|
|
this.isConnected = false;
|
|
// this.dbData = false;
|
|
clearInterval(this.#minuteCronInterval);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Class to represent players stored in the database.
|
|
*/
|
|
export class DatabasePlayer extends BasePlayer {
|
|
readonly isRegistered = true; //no need to check because otherwise constructor throws
|
|
|
|
constructor(license: string, srcPlayerData?: DatabasePlayerType) {
|
|
super(Symbol(`db${license}`));
|
|
if (typeof license !== 'string') {
|
|
throw new Error(`invalid player license`);
|
|
}
|
|
|
|
//Set dbData either from constructor params, or from querying the database
|
|
if (srcPlayerData) {
|
|
this.dbData = srcPlayerData;
|
|
} else {
|
|
const foundData = txCore.database.players.findOne(license);
|
|
if (!foundData) {
|
|
throw new Error(`player not found in database`);
|
|
} else {
|
|
this.dbData = foundData;
|
|
}
|
|
}
|
|
|
|
//fill in data
|
|
this.license = license;
|
|
this.ids = this.dbData.ids;
|
|
this.hwids = this.dbData.hwids;
|
|
this.displayName = this.dbData.displayName;
|
|
this.pureName = this.dbData.pureName;
|
|
}
|
|
|
|
/**
|
|
* Returns a clone of this.dbData
|
|
*/
|
|
getDbData() {
|
|
return cloneDeep(this.dbData);
|
|
}
|
|
}
|
|
|
|
|
|
export type PlayerClass = ServerPlayer | DatabasePlayer;
|