diff --git a/.env.example b/.env.example index 3d7eb45..95a4c5b 100644 --- a/.env.example +++ b/.env.example @@ -1,34 +1,42 @@ -## Server -XMLRPC_HOST="127.0.0.1" -XMLRPC_PORT=5000 -XMLRPC_USER="SuperAdmin" -XMLRPC_PASS="SuperAdmin" - -## Controller - -# comma separated list of admins for the server -ADMINS="" - -EXCLUDED_PLUGINS="tmnf/freezone, tmnf/dedimania" - -# set ansilevel: -# 0 = no color, 1 = 4bit color (console), 2 = 24bit color (rgb) -ANSILEVEL=1 - -# Debug info.. or not -DEBUG=false - -## Plugins -FREEZONE_PASS="" -DEDIMANIA_PASS="" - -TALIMIT=300 -VOTE_TIMEOUT=30 -VOTE_RATIO=0.55 - -## Theme - -# COLOR_TITLE_BG="0ad" -# COLOR_TITLE_FG="fff" -# COLOR_BG="026" -# COLOR_INFO="abc" +## Server +XMLRPC_HOST="127.0.0.1" +XMLRPC_PORT=5000 +XMLRPC_USER="SuperAdmin" +XMLRPC_PASS="SuperAdmin" + +# database connection string examples: + +# DATABASE="postgres://user:pass@127.0.0.1:5432/databaseName" +# DATABASE="mysql://user:pass@127.0.0.1:3306/databaseName" +# DATABASE="sqlite://userdata/local.sqlite" + +DATABASE="sqlite://userdata/local.sqlite" + +## Controller + +# comma separated list of admins for the server +ADMINS="" + +EXCLUDED_PLUGINS="tmnf/freezone, tmnf/dedimania" + +# set ansilevel: +# 0 = no color, 1 = 4bit color (console), 2 = 24bit color (rgb) +ANSILEVEL=1 + +# Debug info.. or not +DEBUG=false + +## Plugins +FREEZONE_PASS="" +DEDIMANIA_PASS="" + +TALIMIT=300 +VOTE_TIMEOUT=30 +VOTE_RATIO=0.55 + +## Theme + +# COLOR_TITLE_BG="0ad" +# COLOR_TITLE_FG="fff" +# COLOR_BG="026" +# COLOR_INFO="abc" diff --git a/.gitignore b/.gitignore index 3c46017..4cdf53d 100644 --- a/.gitignore +++ b/.gitignore @@ -136,6 +136,9 @@ userdata/*.sqlite userdata/*.log userdata/*.json userdata/plugins/* -userdata/drizzle/* +userdata/migrations/* +userdata/schemas/* +!userdata/schemas/.gitkeep +!userdata/migrations/.gitkeep !userdata/plugins/.gitkeep docker-compose.yml diff --git a/.vscode/launch.json b/.vscode/launch.json index 2603206..768a0f3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,53 +1,32 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "bun", - "request": "launch", - "name": "Debug Bun", - - // The path to a JavaScript or TypeScript file to run. - "program": "core/minicontrol.ts", - // The arguments to pass to the program, if any. - "args": [], - - // The working directory of the program. - "cwd": "${workspaceFolder}", - - // The environment variables to pass to the program. - "env": {}, - - // If the environment variables should not be inherited from the parent process. - "strictEnv": false, - - // If the program should be run in watch mode. - // This is equivalent to passing `--watch` to the `bun` executable. - // You can also set this to "hot" to enable hot reloading using `--hot`. - "watchMode": false, - - // If the debugger should stop on the first line of the program. - "stopOnEntry": false, - - // If the debugger should be disabled. (for example, breakpoints will not be hit) - "noDebug": false, - - // The path to the `bun` executable, defaults to your `PATH` environment variable. - "runtime": "bun", - - // The arguments to pass to the `bun` executable, if any. - // Unlike `args`, these are passed to the executable itself, not the program. - "runtimeArgs": [ - "--smol" - ], - }, - { - "type": "bun", - "request": "attach", - "name": "Attach to Bun", - - // The URL of the WebSocket inspector to attach to. - // This value can be retrieved by using `bun --inspect`. - "url": "ws://localhost:6499/", - } - ] - } \ No newline at end of file +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "runtimeArgs": [ + "start" + ], + "runtimeExecutable": "npm", + "name": "Run npm start", + "request": "launch", + "envFile": "${workspaceFolder}/.env", + "type": "node" + }, + { + "name": "Launch via NPM", + "request": "launch", + "runtimeArgs": [ + "run-script", + "debug" + ], + "runtimeExecutable": "npm", + "skipFiles": [ + "/**" + ], + "type": "node" + }, + + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 55d1d99..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - // The path to the `bun` executable. -// "bun.runtime": "/path/to/bun", - - // If support for Bun should be added to the default "JavaScript Debug Terminal". - "bun.debugTerminal.enabled": true, - - // If the debugger should stop on the first line of the program. -"bun.debugTerminal.stopOnEntry": false, -"js/ts.implicitProjectConfig.target": "ESNext" -} diff --git a/README.md b/README.md index 77235c4..9fd5f82 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,9 @@ A very simple plugin host for Trackmania United Forever, Maniaplanet and Trackma # QuickStart -1. `npm i -g bun` -2. `bun install` -3. copy .env.example to .env and configure -4. `bun start` +1. `npm install` +2. copy .env.example to .env and configure +3. `npm start` See [documentation](./documentation/index.md) for more info! @@ -15,7 +14,7 @@ See [documentation](./documentation/index.md) for more info! 1. run controller once to generate database structure 2. `mysqldump -u root -p databasename > xaseco.sql` 3. move `xaseco.sql` to `tools` -5. run bun from Tools folder: `bun xaseco.ts xaseco.sql` +5. run bun from Tools folder: `tsx xaseco.ts xaseco.sql` 6. start controller diff --git a/build.ts b/build.ts deleted file mode 100644 index 5c953c6..0000000 --- a/build.ts +++ /dev/null @@ -1,9 +0,0 @@ -await Bun.build({ - entrypoints: ['./core/minicontrol.ts'], - root: '.', - outdir: './dist', - target: 'bun', - external: ['core/plugins/*'], - format: "esm", - minify: true, -}); diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 6364d71..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/core/gbx/index.ts b/core/gbx/index.ts index 86c0b1c..76d96c4 100644 --- a/core/gbx/index.ts +++ b/core/gbx/index.ts @@ -1,5 +1,6 @@ -import {Buffer} from "buffer"; -import type Server from "core/server"; +import { Buffer } from "node:buffer"; +import { Socket } from 'net'; +import type Server from "../../core/server"; import {Readable} from 'stream'; // @ts-ignore import Serializer from "xmlrpc/lib/serializer"; @@ -21,6 +22,7 @@ export class GbxClient { showErrors: false, throwErrors: true, }; + timeoutHandler:any; promiseCallbacks: { [key: string]: any } = {}; /** @@ -53,24 +55,39 @@ export class GbxClient { host = host || "127.0.0.1"; port = port || 5000; const that = this; - this.socket = await Bun.connect({ - hostname: host, + const socket = new Socket(); + const timeout = 5000; + this.socket = socket; + socket.connect({ + host: host, port: port, - socket: { - end() { + keepAlive: true, + }, () => { + socket.on("connect", () => { + clearTimeout(that.timeoutHandler); + }); + socket.on("end", () => { that.isConnected = false; that.server.onDisconnect("end"); - }, - error(error: any) { + }); + socket.on("error", (error: any) => { that.isConnected = false; that.server.onDisconnect(error.message); - }, - data(socket: any, data: Buffer) { + }); + socket.on("data", (data: Buffer) => { + clearTimeout(that.timeoutHandler); that.handleData(data); - } - - } + }); + socket.on("timeout", () => { + tmc.cli("¤error¤XMLRPC Connection timeout"); + process.exit(1); + }); }); + this.timeoutHandler = setTimeout(() => { + tmc.cli("¤error¤[ERROR] Attempt at connection exceeded timeout value."); + socket.end(); + process.exit(1); + }, timeout); const res: boolean = await new Promise((resolve, reject) => { this.promiseCallbacks['onConnect'] = {resolve, reject}; }); diff --git a/core/log.ts b/core/log.ts index 6224faa..b77de40 100644 --- a/core/log.ts +++ b/core/log.ts @@ -75,7 +75,8 @@ class log { console.log(Tm2Console(str, this.ansiLevel)); } info(str: string) { - console.log(Tm2Console(str, this.ansiLevel)); + const date = new Date(); + console.log(Tm2Console(`$555[${date.toISOString()}] $z` + str, this.ansiLevel)); } warn(str: string) { console.log(Tm2Console(str, this.ansiLevel)); diff --git a/core/migrations/00-create-map.ts b/core/migrations/00-create-map.ts new file mode 100644 index 0000000..1d81218 --- /dev/null +++ b/core/migrations/00-create-map.ts @@ -0,0 +1,41 @@ +import { DataTypes } from 'sequelize'; +import type { Migration } from '../../migrate'; + +export const up: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().createTable('maps', { + uuid: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + }, + name: { + type: DataTypes.STRING, + allowNull: false, + }, + author: { + type: DataTypes.STRING, + allowNull: false + }, + authorNickname: { + type: DataTypes.STRING + }, + authorTime: { + type: DataTypes.INTEGER, + allowNull:false + }, + environment: { + type:DataTypes.STRING + }, + updatedAt: { + type: DataTypes.DATE + }, + createdAt: + { + type: DataTypes.DATE + } + }); +}; + +export const down: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().dropTable('maps'); +}; \ No newline at end of file diff --git a/core/migrations/00-create-maplikes.ts b/core/migrations/00-create-maplikes.ts new file mode 100644 index 0000000..5a23dbd --- /dev/null +++ b/core/migrations/00-create-maplikes.ts @@ -0,0 +1,36 @@ +import { DataTypes } from 'sequelize'; +import type { Migration } from '../../migrate'; + +export const up: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().createTable('maplikes', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + allowNull: false, + primaryKey: true, + }, + login: { + type: DataTypes.STRING, + allowNull: false, + }, + mapUuid: { + type: DataTypes.STRING, + allowNull:false + }, + vote: { + type: DataTypes.FLOAT, + allowNull: false + }, + updatedAt: { + type: DataTypes.DATE + }, + createdAt: + { + type: DataTypes.DATE + } + }); +}; + +export const down: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().dropTable('maplikes'); +}; \ No newline at end of file diff --git a/core/migrations/00-create-player.ts b/core/migrations/00-create-player.ts new file mode 100644 index 0000000..bd29732 --- /dev/null +++ b/core/migrations/00-create-player.ts @@ -0,0 +1,36 @@ +import { DataTypes } from 'sequelize'; +import type { Migration } from '../../migrate'; + +export const up: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().createTable('players', { + login: { + type: DataTypes.STRING, + allowNull: false, + primaryKey: true, + }, + nickname: { + type: DataTypes.STRING, + allowNull: false, + }, + customNick: { + type: DataTypes.STRING + }, + allowOverride: { + type: DataTypes.BOOLEAN + }, + zone: { + type:DataTypes.STRING + }, + updatedAt: { + type: DataTypes.DATE + }, + createdAt: + { + type: DataTypes.DATE + } + }); +}; + +export const down: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().dropTable('players'); +}; \ No newline at end of file diff --git a/core/migrations/00-create-scores.ts b/core/migrations/00-create-scores.ts new file mode 100644 index 0000000..dd5fc74 --- /dev/null +++ b/core/migrations/00-create-scores.ts @@ -0,0 +1,38 @@ +import { DataTypes } from 'sequelize'; +import type { Migration } from '../../migrate'; + +export const up: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().createTable('scores', { + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + mapUuid: { + type: DataTypes.STRING, + allowNull: false, + }, + login: { + type: DataTypes.STRING, + allowNull: false, + }, + time: { + type: DataTypes.INTEGER, + allowNull: false + }, + checkpoints: { + type: DataTypes.STRING + }, + updatedAt: { + type: DataTypes.DATE + }, + createdAt: + { + type: DataTypes.DATE + } + }); +}; + +export const down: Migration = async ({ context: sequelize }) => { + await sequelize.getQueryInterface().dropTable('scores'); +}; \ No newline at end of file diff --git a/core/minicontrol.ts b/core/minicontrol.ts index 6a95626..d85de59 100644 --- a/core/minicontrol.ts +++ b/core/minicontrol.ts @@ -1,465 +1,474 @@ -import PlayerManager, { Player } from './playermanager'; -import Server from './server'; -import UiManager from './uimanager'; -import MapManager from './mapmanager'; -import CommandManager from './commandmanager'; -import SettingsManager from './settingsmanager'; -import { processColorString } from './utils'; -import log from './log'; -import fs from 'fs'; -import Plugin from 'core/plugins'; -import path from 'path'; -import { DepGraph } from "dependency-graph"; - -if (!process.versions.bun) { - log.info(`Please install bun using "npm install -g bun"`); - process.exit(); -} - -export interface GameStruct { - Name: string; - Version?: string; - Build?: string; -} - -/** - * MiniControl class - */ -class MiniControl { - /** - * The version of MiniControl. - */ - readonly brand: string = "$n$o$eeeMINI$o$z$s$abccontrol$z$s¤white¤"; - readonly version: string = "0.3.8"; - /** - * The start time of MiniControl. - */ - readonly startTime: string = Date.now().toString(); - /** - * The admins of MiniControl. - */ - admins: string[] = []; - /** - * The server object. - */ - server: Server; - /** - * The command manager. - */ - chatCmd: CommandManager; - /** - * The map manager. - */ - maps: MapManager; - /** - * The player manager. - */ - players: PlayerManager; - /** - * The UI manager. - */ - ui: UiManager; - /** - * The settings - */ - settings: any = {}; - /** - * The settings manager. - */ - settingsMgr: SettingsManager; - /** - * The colors - */ - colors: { [key: string]: string } = {}; - /** - * The plugins. - */ - plugins: { [key: string]: Plugin } = {}; - pluginDependecies: DepGraph = new DepGraph(); - /** - * The game object. - */ - game: GameStruct; - mapsPath: string = ""; - storage: { [key: string]: any } = {}; - startComplete: boolean = false; - - constructor() { - console.time("Startup"); - this.server = new Server(); - this.maps = new MapManager(); - this.players = new PlayerManager(); - this.ui = new UiManager(); - this.chatCmd = new CommandManager(); - this.settingsMgr = new SettingsManager(); - this.settingsMgr.load(); - this.settings = this.settingsMgr.settings; - this.colors = this.settingsMgr.colors; - this.admins = this.settingsMgr.admins; - this.game = { Name: "" }; - } - - /** - * Gets a player object from the player manager. - * @param login The login of the player. - * @returns A promise that resolves to the player object. - */ - async getPlayer(login: string): Promise { - return await this.players.getPlayer(login); - } - - /** - * Adds chat command - * @param command The command name, should start with / for public or // for admin only - * @param callback The callback function to execute when the command is triggered. - * @param help The help text for the command. - */ - addCommand(command: string, callback: CallableFunction, help: string = "") { - this.chatCmd.addCommand(command, callback, help); - } - - /** - * Removes chat command - * @param command The command name to remove. - */ - removeCommand(command: string) { - this.chatCmd.removeCommand(command); - } - - /** - * @param name name of the plugin folder in ./plugins - * @returns - */ - findPlugin(name: string): string | null { - const dirsToCheck = ["core/plugins/", "userdata/plugins/"]; - for (const dir of dirsToCheck) { - if (fs.existsSync(dir + name + "/index.ts")) { - return (dir + name).replaceAll("\\", "/"); - } - } - return null; - } - - /** - * Loads a plugin to runtime - * @param name name of the plugin folder in ./plugins - * @returns - */ - async loadPlugin(name: string) { - if (!this.plugins[name]) { - const pluginPath = this.findPlugin(name); - if (pluginPath == null) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ does not exist.`; - if (this.startComplete) { - this.cli(msg); - this.chat(msg); - } - return; - } - const plugin = await import(process.cwd() + "/" + pluginPath); - - if (plugin.default == undefined) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤error¤ failed to load. Plugin has no default export.`; - this.cli(msg); - this.chat(msg); - return; - } - if (!(plugin.default.prototype instanceof Plugin)) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ is not a valid plugin.`; - this.cli(msg); - this.chat(msg); - return; - } - - if (!this.pluginDependecies.hasNode(name)) { - this.pluginDependecies.addNode(name); - if (Reflect.has(plugin.default, "depends")) { - for (const dependency of plugin.default.depends) { - if (!dependency.startsWith("game:")) { - this.pluginDependecies.addDependency(name, dependency) - } - } - } - } - - for (const depend of plugin.default.depends) { - if (depend.startsWith("game:")) { - const game = depend.split(":")[1]; - if (game != this.game.Name) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ not loaded. Game is not ¤cmd¤${game}¤white¤.`; - this.cli(msg); - if (this.startComplete) this.chat(msg); - return; - } - } - if (!this.pluginDependecies.hasNode(depend)) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ failed to load. Missing dependency ¤cmd¤${depend}¤white¤.`; - this.cli(msg); - if (this.startComplete) this.chat(msg); - Bun.gc(true); - return; - } - } - - // load and init the plugin - const cls = new plugin.default(); - this.plugins[name] = cls; - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ loaded.`; - await cls.onLoad(); - this.cli(msg); - if (this.startComplete) { - this.chat(msg); - await cls.onStart(); - } - } else { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ already loaded.`; - this.chat(msg) - this.cli(msg); - } - } - - /** - * unloads plugin from runtime, also checks for dependecies, runs onUnload and removes require cache - * @param unloadName name of the plugin folder in ./plugins - * @returns - */ - async unloadPlugin(unloadName: string) { - if (this.plugins[unloadName]) { - const deps = this.pluginDependecies.dependantsOf(unloadName); - if (deps.length > 0) { - const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ cannot be unloaded. It has a dependency of ¤cmd¤${deps.join(", ")}¤white¤.`; - this.cli(msg); - this.chat(msg); - return; - } - const pluginPath = this.findPlugin(unloadName); - if (pluginPath == null) { - const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ does not exist.`; - this.cli(msg); - this.chat(msg); - return; - } - - // unload - await this.plugins[unloadName].onUnload(); - // remove from dependecies - for (const dep of this.plugins[unloadName].getDepends()) { - this.pluginDependecies.removeDependency(unloadName, dep); - } - this.pluginDependecies.removeNode(unloadName); - - delete this.plugins[unloadName]; - const file = path.resolve(process.cwd() + "/" + pluginPath + "/index.ts"); - if (require.cache[file]) { - // eslint-disable-next-line drizzle/enforce-delete-with-where - Loader.registry.delete(file); - delete require.cache[file]; - } else { - this.cli(`$fffFailed to remove require cache for ¤cmd¤${unloadName}¤white¤, hotreload will not work right.`); - } - - Bun.gc(true); - const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ unloaded.`; - this.cli(msg); - this.chat(msg); - } else { - const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ not loaded.` - this.cli(msg); - this.chat(msg); - } - } - - /** - * send message to console - * @param object The object to log. - */ - cli(object: any) { - log.info(processColorString(object.toString())); - } - - /** - * log command to console if debug is enabled - * @param object The object to log. - */ - debug(object: any) { - if (process.env.DEBUG == "true") log.info(processColorString(object.toString())); - } - - /** - * Sends chat message to server - * @param text string to send to chat - * @param login {string | string[]} login(s) to send message to, if undefined sends to all players - */ - chat(text: string, login: undefined | string | string[] = undefined) { - if (login !== undefined) { - const msg = "$9ab$n>$z$s " + text.toString(); - this.server.send("ChatSendServerMessageToLogin", processColorString(msg, "$z$s"), (typeof login == "string") ? login : login.join(",")); - } else { - const msg = "$9ab» ¤info¤" + text.toString(); - this.server.send("ChatSendServerMessage", processColorString(msg, "$z$s")); - } - } - - /** - * Runs MiniControl. - * @ignore Should not be called directly - */ - async run() { - if (this.startComplete) return; - const port = Number.parseInt(process.env.XMLRPC_PORT || "5000"); - this.cli("¤info¤Starting MiniControl..."); - this.cli(`¤info¤Using Bun ¤white¤${Bun.version}`); - this.cli("¤info¤Connecting to Trackmania Dedicated server at ¤white¤" + (process.env.XMLRPC_HOST ?? "127.0.0.1") + ":" + port); - const status = await this.server.connect(process.env.XMLRPC_HOST ?? "127.0.0.1", port); - if (!status) { - this.cli("¤error¤Couldn't connect to server."); - process.exit(); - } - this.cli("¤info¤Connected to Trackmania Dedicated server."); - try { - await this.server.call("Authenticate", process.env.XMLRPC_USER ?? "SuperAdmin", process.env.XMLRPC_PASS ?? "SuperAdmin"); - } catch (e: any) { - this.cli("¤error¤Authenticate to server failed."); - this.cli(e.message); - process.exit(); - } - this.server.send("EnableCallbacks", true); - this.server.send("SendHideManialinkPage"); - this.game = await this.server.call("GetVersion"); - - if (this.game.Name == "Trackmania") { - await this.server.call("SetApiVersion", "2023-04-16"); - this.mapsPath = await this.server.call("GetMapsDirectory"); - await this.server.callScript("XmlRpc.EnableCallbacks", "true"); - } else { - this.mapsPath = await this.server.call("GetTracksDirectory"); - } - - await this.maps.init(); - await this.players.init(); - await this.ui.init(); - await this.beforeInit(); - console.timeEnd("Startup"); - } - - /** - * Executes tasks before MiniControl initialization. - * @ignore Shouldn't be called directly - */ - async beforeInit() { - await this.chatCmd.beforeInit(); - // load plugins - let plugins = fs.readdirSync(process.cwd() + "/core/plugins", { withFileTypes: true, recursive: true }); - plugins = plugins.concat(fs.readdirSync(process.cwd() + "/userdata/plugins", { withFileTypes: true, recursive: true })); - const exclude = process.env.EXCLUDED_PLUGINS?.split(",") || []; - let loadList = []; - for (const plugin of plugins) { - let include = plugin && plugin.isDirectory(); - const directory = plugin.path.replace(path.resolve("core", "plugins"), "").replace(path.resolve("userdata", "plugins"), ""); - if (include) { - let pluginName = plugin.name; - if (directory != "") { - pluginName = (directory + "/" + plugin.name).replaceAll("\\", "/"); - if (pluginName.startsWith("/")) pluginName = pluginName.substring(1); - } - for (const excludeName of exclude) { - if (excludeName == "") continue; - if (pluginName.startsWith(excludeName.trim())) { - include = false; - } - } - if (include) { - loadList.push(pluginName); - } - } - } - - // load metadata - for (const name of loadList) { - const pluginName = this.findPlugin(name); - if (pluginName == null) { - const msg = `¤error¤Didn't find a plugin. resolved plugin name is null.`; - this.cli(msg); - continue; - } - const cls = await import(process.cwd() + "/" + pluginName); - const plugin = cls.default; - if (plugin == undefined) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤error¤ failed to load. Plugin has no default export.`; - this.cli(msg); - continue; - } - if (!(plugin.prototype instanceof Plugin)) { - const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ is not a valid plugin.`; - this.cli(msg); - continue; - } - - this.pluginDependecies.addNode(name); - if (Reflect.has(plugin, "depends")) { - for (const dependency of plugin.depends) { - if (dependency.startsWith("game:")) { - if (dependency != "game:" + this.game.Name) { - this.pluginDependecies.removeNode(name); - break - } - } - if (!this.pluginDependecies.hasNode(dependency)) { - this.pluginDependecies.addNode(dependency); - } - this.pluginDependecies.addDependency(name, dependency) - } - } - } - - for (const plugin of this.pluginDependecies.overallOrder()) { - if (loadList.includes(plugin)) { - await this.loadPlugin(plugin) - } - } - - this.server.send("Echo", this.startTime, "MiniControl"); - } - - /** - * Executes tasks after MiniControl initialization. - * @ignore Should not be called directly - * - */ - async afterStart() { - tmc.cli("¤success¤MiniControl started successfully."); - this.players.afterInit(); - await this.chatCmd.afterInit(); - await this.ui.afterInit(); - const msg = `¤info¤Welcome to ${this.brand} ¤info¤version ¤white¤${this.version}¤info¤!`; - this.chat(msg); - this.cli(msg); - this.startComplete = true; - for (const plugin of Object.values(this.plugins)) { - await plugin.onStart(); - } - } -} - -export const tmc = new MiniControl(); - -declare global { - const tmc: MiniControl -} -(global as any).tmc = tmc; - -(async () => { - (global as any).tmc = tmc; - await tmc.run() -})(); - -process.on('SIGINT', function () { - tmc.server.send("SendHideManialinkPage", 0, false); - process.exit(0); -}); - -process.on("SIGTERM", () => { - tmc.server.send("SendHideManialinkPage", 0, false); - process.exit(0); -}); - +import PlayerManager, { Player } from './playermanager'; +import Server from './server'; +import UiManager from './uimanager'; +import MapManager from './mapmanager'; +import CommandManager from './commandmanager'; +import SettingsManager from './settingsmanager'; +import { processColorString } from './utils'; +import log from './log'; +import fs from 'fs'; +import Plugin from './plugins'; +import path from 'path'; +import { DepGraph } from "dependency-graph"; +import { require } from 'tsx/cjs/api' + +export interface GameStruct { + Name: string; + Version?: string; + Build?: string; +} + + +/** + * MiniControl class + */ +class MiniControl { + /** + * The version of MiniControl. + */ + readonly brand: string = "$n$o$eeeMINI$o$z$s$abccontrol$z$s¤white¤"; + readonly version: string = "0.4.0"; + /** + * The start time of MiniControl. + */ + readonly startTime: string = Date.now().toString(); + /** + * The admins of MiniControl. + */ + admins: string[] = []; + /** + * The server object. + */ + server: Server; + /** + * The command manager. + */ + chatCmd: CommandManager; + /** + * The map manager. + */ + maps: MapManager; + /** + * The player manager. + */ + players: PlayerManager; + /** + * The UI manager. + */ + ui: UiManager; + /** + * The settings + */ + settings: any = {}; + /** + * The settings manager. + */ + settingsMgr: SettingsManager; + /** + * The colors + */ + colors: { [key: string]: string } = {}; + /** + * The plugins. + */ + plugins: { [key: string]: Plugin } = {}; + pluginDependecies: DepGraph = new DepGraph(); + /** + * The game object. + */ + game: GameStruct; + mapsPath: string = ""; + storage: { [key: string]: any } = {}; + startComplete: boolean = false; + + constructor() { + console.time("Startup"); + this.server = new Server(); + this.maps = new MapManager(); + this.players = new PlayerManager(); + this.ui = new UiManager(); + this.chatCmd = new CommandManager(); + this.settingsMgr = new SettingsManager(); + this.settingsMgr.load(); + this.settings = this.settingsMgr.settings; + this.colors = this.settingsMgr.colors; + this.admins = this.settingsMgr.admins; + this.game = { Name: "" }; + } + + /** + * Gets a player object from the player manager. + * @param login The login of the player. + * @returns A promise that resolves to the player object. + */ + async getPlayer(login: string): Promise { + return await this.players.getPlayer(login); + } + + /** + * Adds chat command + * @param command The command name, should start with / for public or // for admin only + * @param callback The callback function to execute when the command is triggered. + * @param help The help text for the command. + */ + addCommand(command: string, callback: CallableFunction, help: string = "") { + this.chatCmd.addCommand(command, callback, help); + } + + /** + * Removes chat command + * @param command The command name to remove. + */ + removeCommand(command: string) { + this.chatCmd.removeCommand(command); + } + + /** + * @param name name of the plugin folder in ./plugins + * @returns + */ + findPlugin(name: string): string | null { + const dirsToCheck = ["./core/plugins/", "./userdata/plugins/"]; + for (const dir of dirsToCheck) { + if (fs.existsSync(dir + name + "/index.ts")) { + return (dir + name).replaceAll("\\", "/"); + } + } + return null; + } + + /** + * Loads a plugin to runtime + * @param name name of the plugin folder in ./plugins + * @returns + */ + async loadPlugin(name: string) { + if (!this.plugins[name]) { + const pluginPath = this.findPlugin(name); + if (pluginPath == null) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ does not exist.`; + if (this.startComplete) { + this.cli(msg); + this.chat(msg); + } + return; + } + let plugin = null; + if (process.platform === "win32") { + plugin = await import("file:///" + process.cwd() + "/" + pluginPath); + } else { + plugin = await import(process.cwd() + "/" + pluginPath); + } + + if (plugin.default == undefined) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤error¤ failed to load. Plugin has no default export.`; + this.cli(msg); + this.chat(msg); + return; + } + if (!(plugin.default.prototype instanceof Plugin)) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ is not a valid plugin.`; + this.cli(msg); + this.chat(msg); + return; + } + + if (!this.pluginDependecies.hasNode(name)) { + this.pluginDependecies.addNode(name); + if (Reflect.has(plugin.default, "depends")) { + for (const dependency of plugin.default.depends) { + if (!dependency.startsWith("game:")) { + this.pluginDependecies.addDependency(name, dependency) + } + } + } + } + + for (const depend of plugin.default.depends) { + if (depend.startsWith("game:")) { + const game = depend.split(":")[1]; + if (game != this.game.Name) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ not loaded. Game is not ¤cmd¤${game}¤white¤.`; + this.cli(msg); + if (this.startComplete) this.chat(msg); + return; + } + } + if (!this.pluginDependecies.hasNode(depend)) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ failed to load. Missing dependency ¤cmd¤${depend}¤white¤.`; + this.cli(msg); + if (this.startComplete) this.chat(msg); + return; + } + } + + // load and init the plugin + try { + tmc.cli(`¤gray¤Loading ¤cmd¤${name}¤white¤...`) + const cls = new plugin.default(); + this.plugins[name] = cls; + await cls.onLoad(); + if (this.startComplete) { + await cls.onStart(); + this.chat(`¤gray¤Plugin ¤cmd¤${name} ¤white¤loaded!`); + } + this.cli("¤gray¤Success."); + } catch (e: any) { + tmc.cli("¤gray¤Error while starting plugin ¤cmd¤" + name); + console.log(e); + } + } else { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ already loaded.`; + this.chat(msg) + this.cli(msg); + } + } + + /** + * unloads plugin from runtime, also checks for dependecies, runs onUnload and removes require cache + * @param unloadName name of the plugin folder in ./plugins + * @returns + */ + async unloadPlugin(unloadName: string) { + if (this.plugins[unloadName]) { + const deps = this.pluginDependecies.dependantsOf(unloadName); + if (deps.length > 0) { + const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ cannot be unloaded. It has a dependency of ¤cmd¤${deps.join(", ")}¤white¤.`; + this.cli(msg); + this.chat(msg); + return; + } + const pluginPath = this.findPlugin(unloadName); + if (pluginPath == null) { + const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ does not exist.`; + this.cli(msg); + this.chat(msg); + return; + } + + // unload + await this.plugins[unloadName].onUnload(); + // remove from dependecies + for (const dep of this.plugins[unloadName].getDepends()) { + this.pluginDependecies.removeDependency(unloadName, dep); + } + this.pluginDependecies.removeNode(unloadName); + + delete this.plugins[unloadName]; + const file = path.resolve(process.cwd() + "/" + pluginPath + "/index.ts"); + if (require.cache[file]) { + // eslint-disable-next-line drizzle/enforce-delete-with-where + // Loader.registry.delete(file); // @TODO check how to do this in tsx + delete require.cache[file]; + } else { + this.cli(`$fffFailed to remove require cache for ¤cmd¤${unloadName}¤white¤, hotreload will not work right.`); + } + const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ unloaded.`; + this.cli(msg); + this.chat(msg); + } else { + const msg = `¤gray¤Plugin ¤cmd¤${unloadName}¤white¤ not loaded.` + this.cli(msg); + this.chat(msg); + } + } + + /** + * send message to console + * @param object The object to log. + */ + cli(object: any) { + log.info(processColorString(object.toString())); + } + + /** + * log command to console if debug is enabled + * @param object The object to log. + */ + debug(object: any) { + if (process.env.DEBUG == "true") log.debug(processColorString(object.toString())); + } + + /** + * Sends chat message to server + * @param text string to send to chat + * @param login {string | string[]} login(s) to send message to, if undefined sends to all players + */ + chat(text: string, login: undefined | string | string[] = undefined) { + if (login !== undefined) { + const msg = "$9ab$n>$z$s " + text.toString(); + this.server.send("ChatSendServerMessageToLogin", processColorString(msg, "$z$s"), (typeof login == "string") ? login : login.join(",")); + } else { + const msg = "$9ab» ¤info¤" + text.toString(); + this.server.send("ChatSendServerMessage", processColorString(msg, "$z$s")); + } + } + + /** + * Runs MiniControl. + * @ignore Should not be called directly + */ + async run() { + if (this.startComplete) return; + const port = Number.parseInt(process.env.XMLRPC_PORT || "5000"); + this.cli("¤info¤Starting MiniControl..."); + this.cli(`¤info¤Using Node ¤white¤${process.version}`); + this.cli("¤info¤Connecting to Trackmania Dedicated server at ¤white¤" + (process.env.XMLRPC_HOST ?? "127.0.0.1") + ":" + port); + const status = await this.server.connect(process.env.XMLRPC_HOST ?? "127.0.0.1", port); + if (!status) { + this.cli("¤error¤Couldn't connect to server."); + process.exit(); + } + this.cli("¤info¤Connected to Trackmania Dedicated server."); + try { + await this.server.call("Authenticate", process.env.XMLRPC_USER ?? "SuperAdmin", process.env.XMLRPC_PASS ?? "SuperAdmin"); + } catch (e: any) { + this.cli("¤error¤Authenticate to server failed."); + this.cli(e.message); + process.exit(); + } + this.server.send("EnableCallbacks", true); + this.server.send("SendHideManialinkPage"); + this.game = await this.server.call("GetVersion"); + + if (this.game.Name == "Trackmania") { + await this.server.call("SetApiVersion", "2023-04-16"); + this.mapsPath = await this.server.call("GetMapsDirectory"); + await this.server.callScript("XmlRpc.EnableCallbacks", "true"); + } else { + this.mapsPath = await this.server.call("GetTracksDirectory"); + } + + await this.maps.init(); + await this.players.init(); + await this.ui.init(); + await this.beforeInit(); + console.timeEnd("Startup"); + } + + /** + * Executes tasks before MiniControl initialization. + * @ignore Shouldn't be called directly + */ + async beforeInit() { + await this.chatCmd.beforeInit(); + // load plugins + let plugins = fs.readdirSync(process.cwd().replaceAll("\\", "/") + "/core/plugins", { withFileTypes: true, recursive: true }); + plugins = plugins.concat(fs.readdirSync(process.cwd().replaceAll("\\", "/") + "/userdata/plugins", { withFileTypes: true, recursive: true })); + const exclude = process.env.EXCLUDED_PLUGINS?.split(",") || []; + let loadList = []; + for (const plugin of plugins) { + let include = plugin && plugin.isDirectory(); + const directory = plugin.parentPath.replaceAll("\\", "/").replace(path.resolve("core", "plugins").replaceAll("\\", "/"), "").replace(path.resolve("userdata", "plugins").replaceAll("\\", "/"), ""); + if (include) { + let pluginName = plugin.name; + if (directory != "") { + pluginName = (directory + "/" + plugin.name).replaceAll("\\", "/"); + if (pluginName.startsWith("/")) pluginName = pluginName.substring(1); + } + for (const excludeName of exclude) { + if (excludeName == "") continue; + if (pluginName.startsWith(excludeName.trim())) { + include = false; + } + } + if (include) { + loadList.push(pluginName); + } + } + } + + // load metadata + for (const name of loadList) { + const pluginName = this.findPlugin(name); + if (pluginName == null) { + const msg = `¤error¤Didn't find a plugin. resolved plugin name is null.`; + this.cli(msg); + continue; + } + let cls = null; + if (process.platform === "win32") { + cls = await import("file:///" + process.cwd() + "/" + pluginName); + } else { + cls = await import(process.cwd() + "/" + pluginName); + } + const plugin = cls.default; + if (plugin == undefined) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤error¤ failed to load. Plugin has no default export.`; + this.cli(msg); + continue; + } + if (!(plugin.prototype instanceof Plugin)) { + const msg = `¤gray¤Plugin ¤cmd¤${name}¤white¤ is not a valid plugin.`; + this.cli(msg); + continue; + } + + this.pluginDependecies.addNode(name); + if (Reflect.has(plugin, "depends")) { + for (const dependency of plugin.depends) { + if (dependency.startsWith("game:")) { + if (dependency != "game:" + this.game.Name) { + this.pluginDependecies.removeNode(name); + break + } + } + if (!this.pluginDependecies.hasNode(dependency)) { + this.pluginDependecies.addNode(dependency); + } + this.pluginDependecies.addDependency(name, dependency) + } + } + } + + for (const plugin of this.pluginDependecies.overallOrder()) { + if (loadList.includes(plugin)) { + await this.loadPlugin(plugin) + } + } + + this.server.send("Echo", this.startTime, "MiniControl"); + } + + /** + * Executes tasks after MiniControl initialization. + * @ignore Should not be called directly + * + */ + async afterStart() { + tmc.cli("¤success¤MiniControl started successfully."); + this.players.afterInit(); + await this.chatCmd.afterInit(); + await this.ui.afterInit(); + const msg = `¤info¤Welcome to ${this.brand} ¤info¤version ¤white¤${this.version}¤info¤!`; + this.chat(msg); + this.cli(msg); + this.startComplete = true; + for (const plugin of Object.values(this.plugins)) { + await plugin.onStart(); + } + } +} + +export const tmc = new MiniControl(); + +declare global { + const tmc: MiniControl +} +(global as any).tmc = tmc; + +(async () => { + (global as any).tmc = tmc; + await tmc.run() +})(); + +process.on('SIGINT', function () { + tmc.server.send("SendHideManialinkPage", 0, false); + process.exit(0); +}); + +process.on("SIGTERM", () => { + tmc.server.send("SendHideManialinkPage", 0, false); + process.exit(0); +}); + diff --git a/core/playermanager.ts b/core/playermanager.ts index 87efe4f..43c5bfc 100644 --- a/core/playermanager.ts +++ b/core/playermanager.ts @@ -1,5 +1,4 @@ -import { sleep } from "bun"; -import { clone } from "./utils"; +import { clone, sleep } from "./utils"; interface PlayerRanking { Path: string; @@ -19,6 +18,7 @@ interface LadderStats { nbrMatchLosses: number; TeamName: string; } + /** * Player class */ @@ -53,7 +53,7 @@ export class Player { data[key] = data[key].replace(/[$][lh]\[.*?](.*?)([$][lh])?/i, "$1").replaceAll(/[$][lh]/gi, ""); } if (k == "flags") { - this.spectatorTarget = Math.floor(data.Flags / 10000); + this.spectatorTarget = Math.floor(data.SpecatorStatus / 10000); } this[k] = data[key]; } @@ -104,9 +104,9 @@ export default class PlayerManager { } private async onPlayerConnect(data: any) { - await sleep(100); const login = data[0]; - if (login) { + if (login) { + await sleep(100); // @TODO check if this is really needed const player = await this.getPlayer(login); tmc.server.emit("TMC.PlayerConnect", player); } else { @@ -133,7 +133,7 @@ export default class PlayerManager { * get players objects * @returns {Player[]} Returns clone of the current playerlist */ - get(): Player[] { + getAll(): Player[] { return Object.values(this.players); } @@ -156,13 +156,17 @@ export default class PlayerManager { */ async getPlayer(login: string): Promise { if (this.players[login]) return this.players[login]; - tmc.debug(`$888Player ${login} not found, fetching from server.`); - - const data = await tmc.server.call("GetDetailedPlayerInfo", login); - const player = new Player(); - await player.syncFromDetailedPlayerInfo(data); - this.players[login] = player; - return player; + + try { + tmc.debug(`$888Player ${login} not found, fetching from server.`); + const data = await tmc.server.call("GetDetailedPlayerInfo", login); + const player = new Player(); + await player.syncFromDetailedPlayerInfo(data); + this.players[login] = player; + return player; + } catch (e: any) { + return new Player(); + } } /** diff --git a/core/plugins/admin/LocalMapsWindow.ts b/core/plugins/admin/LocalMapsWindow.ts index fa09138..62b2e78 100644 --- a/core/plugins/admin/LocalMapsWindow.ts +++ b/core/plugins/admin/LocalMapsWindow.ts @@ -1,4 +1,4 @@ -import ListWindow from 'core/ui/listwindow'; +import ListWindow from '../../ui/listwindow'; export default class LocalMapsWindow extends ListWindow { diff --git a/core/plugins/admin/ModeSettingsWindow.ts b/core/plugins/admin/ModeSettingsWindow.ts index 0ca60d4..25c4785 100644 --- a/core/plugins/admin/ModeSettingsWindow.ts +++ b/core/plugins/admin/ModeSettingsWindow.ts @@ -1,5 +1,5 @@ -import ListWindow from "core/ui/listwindow"; -import {castType} from "core/utils"; +import ListWindow from "../../ui/listwindow"; +import {castType} from "../../utils"; export default class ModeSettingsWindow extends ListWindow { diff --git a/core/plugins/admin/PlayerListsWindow.ts b/core/plugins/admin/PlayerListsWindow.ts index 9e738f8..c42ebed 100644 --- a/core/plugins/admin/PlayerListsWindow.ts +++ b/core/plugins/admin/PlayerListsWindow.ts @@ -1,4 +1,4 @@ -import ListWindow from 'core/ui/listwindow'; +import ListWindow from "../../ui/listwindow"; export default class PlayerListWindow extends ListWindow { diff --git a/core/plugins/admin/index.ts b/core/plugins/admin/index.ts index 5f49cf0..58faa23 100644 --- a/core/plugins/admin/index.ts +++ b/core/plugins/admin/index.ts @@ -1,6 +1,6 @@ -import { castType, escape, removeColors } from "core/utils"; +import { castType, escape, removeColors } from "../../utils"; import ModeSettingsWindow from "./ModeSettingsWindow"; -import Plugin from "core/plugins"; +import Plugin from "../../plugins"; import fs from "fs"; import LocalMapsWindow from "./LocalMapsWindow"; import PlayerListsWindow from "./PlayerListsWindow"; @@ -124,7 +124,7 @@ export default class AdminPlugin extends Plugin { return; } - if (tmc.game.Name == "Trackmania") { + if (tmc.game.Name == "Trackmania" ||tmc.game.Name == "ManiaPlanet") { if (!params[0]) { return tmc.chat("¤cmd¤//talimit ¤info¤needs numeric value in seconds"); } @@ -248,12 +248,19 @@ export default class AdminPlugin extends Plugin { } }, "Calls server method"); tmc.addCommand("//wu", async (login: string, params: string[]) => { - tmc.server.send("SetWarmUp", true); + if (tmc.game.Name == "TmForever") { + tmc.server.send("SetWarmUp", true); + } }, "Starts warmup"); tmc.addCommand("//endwu", async (login: string, params: string[]) => { - tmc.server.send("SetWarmUp", false); + if (tmc.game.Name == "TmForever") { + tmc.server.send("SetWarmUp", false); + } else { + tmc.server.callScript("Trackmania.WarmUp.ForceStop"); + } }, "end warmup"); + tmc.addCommand("//addlocal", this.cmdAddLocal.bind(this), "Adds local map to playlist"); tmc.addCommand("//modecommand", async (login: string, params: string[]) => { if (!params[0]) { diff --git a/core/plugins/announces/index.ts b/core/plugins/announces/index.ts index 62fb2da..6787a2e 100644 --- a/core/plugins/announces/index.ts +++ b/core/plugins/announces/index.ts @@ -1,7 +1,6 @@ -import type { Player } from "core/playermanager"; -import Plugin from "core/plugins"; -import type { Record } from "core/plugins/records"; -import { formatTime } from 'core/utils'; +import type { Player } from "../../playermanager"; +import Plugin from "../index"; +import { formatTime } from '../../utils'; export default class Announces extends Plugin { async onLoad() { @@ -30,7 +29,7 @@ export default class Announces extends Plugin { } async onPlayerConnect(player: Player) { - tmc.chat(`¤info¤Welcome to ¤white¤${tmc.server.name} ¤info¤(${tmc.brand} ¤info¤version ¤white¤${tmc.version}¤info¤)`, player.login); + tmc.chat(`${tmc.brand} ¤info¤version ¤white¤${tmc.version}`, player.login); const msg = `¤white¤${player.nickname}¤info¤ from ¤white¤${player.path.replace("World|", "").replaceAll("|", ", ")} ¤info¤joins!`; tmc.chat(msg); tmc.cli(msg); @@ -42,12 +41,12 @@ export default class Announces extends Plugin { tmc.cli(msg); } - async onNewRecord(data: any, records: Record[]) { + async onNewRecord(data: any, records: any[]) { const newRecord = data.record; - tmc.chat(`¤white¤${newRecord.nickname}¤rec¤ has set a new $fff1. ¤rec¤server record ¤white¤${formatTime(newRecord.time)}¤rec¤!`); + tmc.chat(`¤white¤${newRecord.player.nickname}¤rec¤ has set a new $fff1. ¤rec¤server record ¤white¤${formatTime(newRecord.time)}¤rec¤!`); } - async onUpdateRecord(data: any, records: Record[]) { + async onUpdateRecord(data: any, records: any[]) { const newRecord = data.record; const oldRecord = data.oldRecord; let extrainfo = ""; @@ -60,21 +59,21 @@ export default class Announces extends Plugin { } if (oldRecord.time == newRecord.time) { - tmc.chat(`¤white¤${newRecord.nickname}¤rec¤ equalled their ¤white¤${newRecord.rank}. ¤rec¤server record ¤white¤${formatTime(newRecord.time)}¤rec¤!`, newRecord.login); + tmc.chat(`¤white¤${newRecord.player.nickname}¤rec¤ equalled their ¤white¤${newRecord.rank}. ¤rec¤server record ¤white¤${formatTime(newRecord.time)}¤rec¤!`, newRecord.login); return; } - tmc.chat(`¤white¤${newRecord.nickname}¤rec¤ improved ¤white¤${newRecord.rank}. ¤rec¤server record ¤white¤${formatTime(newRecord.time)}¤rec¤ ${extrainfo}!`, recipient); + tmc.chat(`¤white¤${newRecord.player.nickname}¤rec¤ improved ¤white¤${newRecord.rank}. ¤rec¤server record ¤white¤${formatTime(newRecord.time)}¤rec¤ ${extrainfo}!`, recipient); } async onSyncRecord(data: any) { const map = tmc.maps.getMap(data.mapUid); - const records: Record[] = data.records; + const records: any[] = data.records; if (records.length === 0) { tmc.chat(`¤rec¤No server records for ¤white¤${map?.Name} ¤rec¤!`); return; } - const msg = `¤rec¤Server record for ¤white¤${map?.Name} ¤rec¤by ¤white¤${records[0].nickname}`; // ¤rec¤ time ¤white¤${formatTime(records[0].time)} + const msg = `¤rec¤Server record for ¤white¤${map?.Name} ¤rec¤by ¤white¤${records[0].player.nickname}`; // ¤rec¤ time ¤white¤${formatTime(records[0].time)} tmc.chat(msg); } } diff --git a/core/plugins/chat/index.ts b/core/plugins/chat/index.ts index db3328c..02b7ae7 100644 --- a/core/plugins/chat/index.ts +++ b/core/plugins/chat/index.ts @@ -1,4 +1,4 @@ -import Plugin from 'core/plugins'; +import Plugin from '../index'; export default class Chat extends Plugin { enabled: boolean = false; @@ -18,7 +18,7 @@ export default class Chat extends Plugin { try { await tmc.server.call("ChatEnableManualRouting", false, false); } catch (e: any) { - console.log(e.message); + tmc.cli(e.message); } tmc.server.removeListener("Trackmania.PlayerChat", this.onPlayerChat.bind(this)); this.enabled = false; diff --git a/core/plugins/database/index.ts b/core/plugins/database/index.ts index 32a5227..08eccf4 100644 --- a/core/plugins/database/index.ts +++ b/core/plugins/database/index.ts @@ -1,50 +1,78 @@ -import { drizzle, type BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'; -import { migrate } from "drizzle-orm/bun-sqlite/migrator"; -import { Database } from 'bun:sqlite'; -import { eq } from "drizzle-orm"; -import type { Logger } from 'drizzle-orm/logger'; -import type { Player as PlayerType } from 'core/playermanager'; -import { Player } from 'core/schemas/players'; -import Plugin from 'core/plugins'; +import { Sequelize } from 'sequelize-typescript'; +import type { Player as PlayerType } from '../../playermanager'; +import Plugin from '../../plugins'; +import { chunkArray, sleep } from '../../utils'; +import Map from '../../schemas/map.model'; +import Player from '../../schemas/players.model'; +import { MigrationError, SequelizeStorage, Umzug } from 'umzug'; +import { removeColors } from '../../utils'; -class SqliteLogger implements Logger { - logQuery(query: string, params: unknown[]): void { - tmc.debug(`$d7c${query}`); - } -} - -export default class SqliteDb extends Plugin { +export default class GenericDb extends Plugin { async onLoad() { - const sqlite = new Database(process.cwd() + '/userdata/local.sqlite'); - const client = drizzle(sqlite, { - logger: new SqliteLogger() - }); - console.log("Running Migrates..."); + let sequelize; + const dbString = (process.env['DATABASE'] ?? "").split("://", 1)[0]; + if (!["sqlite", "mysql", "postgres"].includes(dbString)) { + tmc.cli("¤error¤Seems you .env is missing 'DATABASE=' define or the database not sqlite, mysql or postgres"); + process.exit(1); + } + try { - migrate(client, { - migrationsFolder: "./userdata/drizzle" + sequelize = new Sequelize(process.env['DATABASE'] ?? "", { + logging(sql, timing) { + tmc.debug(`$d7c${removeColors(sql)}`); + }, }); + tmc.cli("¤info¤Trying to connect database...") + await sequelize.authenticate(); + tmc.cli("¤success¤Success!"); } catch (e: any) { - tmc.cli("¤error¤Error running migrations: ¤white¤" + e.message); + tmc.cli("¤error¤" + e.message); process.exit(1); } - tmc.storage['sqlite'] = client; - tmc.cli("¤success¤Database connected."); - tmc.server.addListener("TMC.PlayerConnect", this.onPlayerConnect, this); + try { + for (const path of ["./core/migrations/", "./userdata/migrations/"]) { + const migrator = new Umzug({ + migrations: { + glob: [path + '*.ts', { cwd: process.cwd() }], + }, + context: sequelize, + storage: new SequelizeStorage({ + sequelize, + }), + logger: { + debug: (message) => { }, + error: (message) => { tmc.cli("$f00" + message) }, + warn: (message) => { tmc.cli("$fa0" + message) }, + info: (message) => { tmc.cli("$5bf" + message.event + " $fff" + message.name) }, + } + }); + tmc.cli("¤info¤Running migrations for " + path); + await migrator.up(); + tmc.cli("¤success¤Success!"); + } + sequelize.addModels([Map, Player]); + tmc.storage['db'] = sequelize; + tmc.server.addListener("TMC.PlayerConnect", this.onPlayerConnect, this); + tmc.server.addListener("Trackmania.MapListModified", this.onMapListModified, this); + } catch (e: any) { + tmc.cli("¤error¤" + e.message); + process.exit(1); + } } async onUnload() { - if (tmc.storage['sqlite']) { - await tmc.storage['sqlite'].close(); - delete (tmc.storage['sqlite']); + if (tmc.storage['db']) { + await tmc.storage['db'].close(); + delete (tmc.storage['db']); } tmc.server.removeListener("TMC.PlayerConnect", this.onPlayerConnect.bind(this)); } async onStart() { await this.syncPlayers(); + await this.syncMaps(); } async onPlayerConnect(player: any) { @@ -52,30 +80,66 @@ export default class SqliteDb extends Plugin { } async syncPlayer(player: PlayerType) { - if (!tmc.storage['sqlite']) return; - const db: BunSQLiteDatabase = tmc.storage['sqlite']; - const query = await db.select().from(Player).where(eq(Player.login, player.login)); - if (query.length == 0) { - await db.insert(Player).values({ + let dbPlayer = await Player.findByPk(player.login); + if (dbPlayer == null) { + dbPlayer = await Player.create({ login: player.login, nickname: player.nickname, + path: player.path, }); } else { - await db.update(Player).set({ + dbPlayer.update({ nickname: player.nickname, - }).where(eq(Player.login, player.login)); - - if (query[0] && query[0].customNick) { - tmc.cli("Setting nickname to " + query[0].customNick); - player.set("nickname", query[0].customNick); - } + path: player.path + }); + } + if (dbPlayer && dbPlayer.customNick) { + tmc.cli("Setting nickname to " + dbPlayer.customNick); + player.set("nickname", dbPlayer.customNick); } } + async syncPlayers() { - const players = tmc.players.get(); + const players = tmc.players.getAll(); for (const player of players) { await this.syncPlayer(await tmc.players.getPlayer(player.login)); } } + + async onMapListModified(data: any) { + if (data[2] === true) { + await sleep(250); + await this.syncMaps(); + } + } + + async syncMaps() { + const serverUids = tmc.maps.getUids(); + const result = await Map.findAll(); + const dbUids = result.map((value: any) => value.uuid); + const missingUids = chunkArray(serverUids.filter(item => dbUids.indexOf(item) < 0), 50); + for (const groups of missingUids) { + let missingMaps: any[] = []; + for (const uid of groups) { + const map = tmc.maps.getMap(uid); + if (!map) continue; + const outMap = { + uuid: map.UId, + name: map.Name, + author: map.Author, + authorNickname: map.AuthorNickname ?? "", + authorTime: map.AuthorTime, + environment: map.Environnement, + }; + missingMaps.push(outMap); + } + + try { + Map.bulkCreate(missingMaps); + } catch (e: any) { + tmc.cli(`¤error¤` + e.message); + } + } + } } \ No newline at end of file diff --git a/core/plugins/debugtool/index.ts b/core/plugins/debugtool/index.ts index d7723a3..c989773 100644 --- a/core/plugins/debugtool/index.ts +++ b/core/plugins/debugtool/index.ts @@ -1,18 +1,16 @@ -import { generateHeapSnapshot } from "bun"; -import { memInfo } from "core/utils"; -import Plugin from "core/plugins"; +import { memInfo } from "../../utils"; +import Plugin from "../../plugins"; import tm from 'tm-essentials'; -import Widget from 'core/ui/widget'; +import Widget from '../../ui/widget'; export default class DebugTool extends Plugin { widget: Widget | null = null; - intervalId: NodeJS.Timer | null = null; + intervalId: any | null = null; async onLoad() { if (process.env.DEBUG == "true") { this.widget = new Widget("core/plugins/debugtool/widget.twig"); this.widget.pos = { x: 159, y: -60 }; - tmc.addCommand("//heap", this.cmdHeap.bind(this), "Log heap memory usage"); tmc.addCommand("//addfake", this.cmdFakeUsers.bind(this), "Connect Fake users"); tmc.addCommand("//removefake", this.cmdRemoveFakeUsers.bind(this), "Connect Fake users"); } @@ -20,7 +18,7 @@ export default class DebugTool extends Plugin { await this.displayMemInfo(); this.intervalId = setInterval(() => { this.displayMemInfo(); - }, 60000) as NodeJS.Timer; + }, 60000) as any; } async onUnload() { @@ -48,12 +46,6 @@ export default class DebugTool extends Plugin { tmc.chat("¤info¤Memory usage: " + mem, login); } - async cmdHeap(login: string, args: string[]) { - const snapshot = generateHeapSnapshot(); - await Bun.write("./heap.heapsnapshot", JSON.stringify(snapshot, null, 4)); - tmc.chat("¤info¤Heap snapshot written to ¤white¤heap.heapsnapshot", login); - } - async displayMemInfo() { const mem = memInfo(); diff --git a/core/plugins/funcommands/index.ts b/core/plugins/funcommands/index.ts index e2fc06b..ec9ab4f 100644 --- a/core/plugins/funcommands/index.ts +++ b/core/plugins/funcommands/index.ts @@ -1,4 +1,4 @@ -import Plugin from 'core/plugins'; +import Plugin from '../../plugins'; export default class FunCommands extends Plugin { diff --git a/core/plugins/healthcheck/index.ts b/core/plugins/healthcheck/index.ts index 7cddd7a..1a6de3b 100644 --- a/core/plugins/healthcheck/index.ts +++ b/core/plugins/healthcheck/index.ts @@ -1,37 +1,44 @@ -import type { Socket, TCPSocketListener } from "bun"; +import { Server, type Socket } from "net"; import Plugin from ".."; +import { isDocker } from "../../utils"; export default class HealthCheck extends Plugin { - socket: TCPSocketListener | null = null; + server: Server | null = null; async onLoad() { - this.socket = Bun.listen({ - port: 3000, - hostname: "127.0.0.1", - socket: { - error(error: any) { - tmc.cli(`¤error¤HealthCheck: ${error.message}`); - }, - data(socket: Socket, data: Buffer) { - const message = data.toString("utf-8").trim(); - if (message == "ping") { - // sends 0 if the service is healthy, 1 if it's not - socket.end(tmc.startComplete ? "0" : "1"); - tmc.debug("¤info¤HealthCheck: Ping received."); - } - }, - open() { - tmc.debug("¤info¤HealthCheck: Connection opened."); + if (!isDocker()) { + tmc.cli("¤info¤HealthCheck: disabled, docker not detected."); + return; + } + tmc.cli("¤info¤HealthCheck: enabled, docker detected."); + + this.server = new Server((socket: Socket) => { + socket.on("error", (error: any) => { + tmc.cli(`¤error¤HealthCheck: ${error.message}`); + }) + + socket.on("data", (data: Buffer) => { + const message = data.toString("utf-8").trim(); + if (message == "ping") { + // sends 0 if the service is healthy, 1 if it's not + socket.end(tmc.startComplete ? "0" : "1"); + tmc.debug("¤info¤HealthCheck: Ping received."); } - } + }); + + socket.on("open", () => { + tmc.debug("¤info¤HealthCheck: Connection opened."); + }); }); + + this.server.listen(3000); } async onUnload() { - if (this.socket) { - this.socket.stop(); - this.socket = null; + if (this.server) { + this.server.close(); + this.server = null; } } } \ No newline at end of file diff --git a/core/plugins/index.ts b/core/plugins/index.ts index 326c088..be1e247 100644 --- a/core/plugins/index.ts +++ b/core/plugins/index.ts @@ -1,4 +1,3 @@ -import Statistics from "userdata/plugins/kacky/stats"; export default abstract class Plugin { /** "game:TmForever | game:ManiaPlanet | game:Trackmania or plugin name to depend" */ diff --git a/core/plugins/maplikes/index.ts b/core/plugins/maplikes/index.ts index f3cbdc8..29f5851 100644 --- a/core/plugins/maplikes/index.ts +++ b/core/plugins/maplikes/index.ts @@ -1,7 +1,6 @@ -import Plugin from "core/plugins"; -import type {BunSQLiteDatabase} from "drizzle-orm/bun-sqlite"; -import {and, eq} from "drizzle-orm"; -import {MapLikes as Likes} from "core/schemas/maplikes"; +import { Sequelize } from "sequelize-typescript"; +import Plugin from "../../plugins"; +import Likes from "../../schemas/maplikes.model"; export interface Like { login: string; @@ -14,6 +13,7 @@ export default class MapLikes extends Plugin { votes: Like[] = []; async onLoad() { + tmc.storage['db'].addModels([Likes]); tmc.addCommand("/++", this.onLike.bind(this), "Like a map"); tmc.addCommand("/--", this.onDislike.bind(this), "Dislike a map"); tmc.server.addListener("Trackmania.BeginMap", this.syncVotes, this); @@ -29,17 +29,15 @@ export default class MapLikes extends Plugin { tmc.removeCommand("/--"); tmc.server.removeListener("Trackmania.BeginMap", this.syncVotes); tmc.server.removeListener("Trackmania.PlayerChat", this.onPlayerChat); - this.votes = []; + this.votes = []; } async syncVotes() { - if (!tmc.storage['sqlite']) return; if (!tmc.maps.currentMap) return; - const db: BunSQLiteDatabase = tmc.storage['sqlite']; - const votes = await db.select().from(Likes).where(eq(Likes.mapUuid, tmc.maps.currentMap.UId)); + const votes = await Likes.findAll({ where: { mapUuid: tmc.maps.currentMap.UId } }); this.votes = []; for (const vote of votes) { - this.votes.push({login: vote.login, vote: vote.vote, updatedAt: vote.updatedAt || ""}); + this.votes.push({ login: vote.login, vote: vote.vote, updatedAt: vote.updatedAt || "" }); } tmc.server.emit("Plugin.MapLikes.onSync", this.votes); } @@ -53,10 +51,10 @@ export default class MapLikes extends Plugin { if (text === "++") { await this.updateVote(login, 1); } - + if (text === "--") { await this.updateVote(login, -1); - } + } } async onLike(login: string) { @@ -68,22 +66,22 @@ export default class MapLikes extends Plugin { } async updateVote(login: string, value: number = 0) { - if (!tmc.storage['sqlite']) return; if (!tmc.maps.currentMap) return; - const db: BunSQLiteDatabase = tmc.storage['sqlite']; - const query = await db.select().from(Likes).where(and(eq(Likes.mapUuid, tmc.maps.currentMap.UId), eq(Likes.login, login))); - if (query.length == 0) { - await db.insert(Likes).values({ + let mapLike = await Likes.findOne( + { + where: { + mapUuid: tmc.maps.currentMap.UId, + login: login, + } + }); + if (!mapLike) { + mapLike = await Likes.create({ mapUuid: tmc.maps.currentMap.UId, login: login, vote: value - }); - } else { - await db.update(Likes).set({ - vote: value, - updatedAt: new Date().toISOString(), - }).where(and(eq(Likes.mapUuid, tmc.maps.currentMap.UId), eq(Likes.login, login))); + }) } + await mapLike.update({ vote: value }); await this.syncVotes(); } diff --git a/core/plugins/maps/index.ts b/core/plugins/maps/index.ts index 2398163..40ef232 100644 --- a/core/plugins/maps/index.ts +++ b/core/plugins/maps/index.ts @@ -1,7 +1,7 @@ import tm from 'tm-essentials'; import MapsWindow from './mapsWindow'; -import { clone, escape, formatTime, removeColors } from 'core/utils'; -import Plugin from 'core/plugins'; +import { clone, escape, formatTime, removeColors } from '../../utils'; +import Plugin from '../../plugins'; import QueueWindow from './queueWIndow'; export interface Map { @@ -25,7 +25,7 @@ export default class Maps extends Plugin { tmc.addCommand("/jb", this.cmdListQueue.bind(this), "List maps in queue"); tmc.addCommand("/drop", this.cmdDrop.bind(this), "Drop Map from queue"); tmc.addCommand("//cjb", this.cmdClearQueue.bind(this), "clear queue"); - if (tmc.game.Name === "TmForever") { + if (tmc.game.Name === "TmForever" || tmc.game.Name === "ManiaPlanet" ) { tmc.server.addListener("Trackmania.EndRace", this.onEndRace, this); } else { tmc.server.addListener("Trackmania.Podium_Start", this.onEndRace, this); @@ -136,8 +136,12 @@ export default class Maps extends Plugin { if (this.queue.length > 0) { const map = this.queue.shift(); if (map) { + try { await tmc.server.call("ChooseNextMap", map.File); tmc.chat(`¤info¤Map ¤white¤${map.Name} ¤info¤chosen by ¤white¤${map.QueueNickName}`); + } catch (e:any) { + tmc.cli(`¤error¤${e.message}`); + } } } } diff --git a/core/plugins/maps/mapsWindow.ts b/core/plugins/maps/mapsWindow.ts index c47ba3e..963a73e 100644 --- a/core/plugins/maps/mapsWindow.ts +++ b/core/plugins/maps/mapsWindow.ts @@ -1,6 +1,6 @@ -import Confirm from 'core/ui/confirm'; -import ListWindow from 'core/ui/listwindow'; -import { formatTime, escape, removeColors } from 'core/utils'; +import Confirm from '../../ui/confirm'; +import ListWindow from '../../ui/listwindow'; +import { formatTime, escape, removeColors } from '../../utils'; export default class MapsWindow extends ListWindow { params: string[] = []; diff --git a/core/plugins/maps/queueWIndow.ts b/core/plugins/maps/queueWIndow.ts index 5c19f53..780b2e6 100644 --- a/core/plugins/maps/queueWIndow.ts +++ b/core/plugins/maps/queueWIndow.ts @@ -1,4 +1,4 @@ -import ListWindow from 'core/ui/listwindow'; +import ListWindow from '../../ui/listwindow'; export default class QueueWindow extends ListWindow { diff --git a/core/plugins/players/PlayerWindow.ts b/core/plugins/players/PlayerWindow.ts index 85d983b..25c9deb 100644 --- a/core/plugins/players/PlayerWindow.ts +++ b/core/plugins/players/PlayerWindow.ts @@ -1,5 +1,5 @@ -import Confirm from 'core/ui/confirm'; -import ListWindow from 'core/ui/listwindow'; +import Confirm from '../../ui/confirm'; +import ListWindow from '../../ui/listwindow'; export default class PlayerWindow extends ListWindow { diff --git a/core/plugins/players/index.ts b/core/plugins/players/index.ts index e9b0bca..8da4fe7 100644 --- a/core/plugins/players/index.ts +++ b/core/plugins/players/index.ts @@ -1,4 +1,4 @@ -import Plugin from "core/plugins"; +import Plugin from "../../plugins"; import PlayerWindow from "./PlayerWindow"; @@ -27,7 +27,7 @@ export default class Players extends Plugin { const window = new PlayerWindow(login); window.size = { width: 195, height: 95 }; window.title = "Players"; - window.setItems(tmc.players.get()); + window.setItems(tmc.players.getAll()); window.setColumns([ { key: "nickname", title: "Nickname", width: 50 }, { key: "login", title: "Login", width: 50, type:"entry" }, diff --git a/core/plugins/records/index.ts b/core/plugins/records/index.ts index e080bd8..63c9638 100644 --- a/core/plugins/records/index.ts +++ b/core/plugins/records/index.ts @@ -1,59 +1,25 @@ -import { type BunSQLiteDatabase } from 'drizzle-orm/bun-sqlite'; -import Plugin from "core/plugins"; -import { Score } from "core/schemas/scores"; -import { Player } from "core/schemas/players"; -import { eq, asc, and } from "drizzle-orm"; -import { clone, escape, removeLinks, formatTime } from "core/utils"; - -import RecordsWindow from "core/plugins/records/recordsWindow.ts"; - -export class Record { - login: string = ""; - nickname: string = ""; - rank: number = 0; - time: number = 0; - avgTime: number = 0; - totalFinishes: number = 0; - checkpoints: string = ""; - createdAt: string = ""; - updatedAt: string = ""; - - fromScore(score: any) { - if (score.rank) { - this.rank = score.rank; - } - this.login = score.login; - if (score.nickname) { - this.nickname = score.nickname; - } - this.time = score.time; - this.avgTime = score.avgTime; - this.totalFinishes = score.totalFinishes; - this.checkpoints = score.checkpoints; - this.createdAt = score.createdAt; - this.updatedAt = score.updatedAt; - return this; - } -} +import Plugin from "../../plugins"; +import Score from "../../schemas/scores.model"; +import Player from "../../schemas/players.model"; +import { clone, escape, removeLinks, formatTime } from "../../utils"; +import RecordsWindow from "./recordsWindow"; +import { Op } from "sequelize"; export default class Records extends Plugin { static depends: string[] = ["database"]; - db: BunSQLiteDatabase | null = null; - records: Record[] = []; + records: Score[] = []; currentMapUid: string = ""; limit: number = 100; async onLoad() { - if (!tmc.storage['sqlite']) return; - this.db = tmc.storage['sqlite']; + tmc.storage['db'].addModels([Score]); tmc.server.addListener("Trackmania.BeginMap", this.onBeginMap, this); tmc.server.addListener("TMC.PlayerFinish", this.onPlayerFinish, this); tmc.chatCmd.addCommand("/records", this.cmdRecords.bind(this), "Display records"); } async onUnload() { - this.db = null; tmc.server.removeListener("Trackmania.BeginMap", this.onBeginMap.bind(this)); tmc.server.removeListener("TMC.PlayerFinish", this.onPlayerFinish.bind(this)); tmc.chatCmd.removeCommand("/records"); @@ -68,12 +34,9 @@ export default class Records extends Plugin { action: "/records" }); } - if (!this.db) return; if (!tmc.maps.currentMap?.UId) return; this.currentMapUid = tmc.maps.currentMap.UId; await this.syncRecords(tmc.maps.currentMap.UId); - - } async onBeginMap(data: any) { @@ -84,13 +47,13 @@ export default class Records extends Plugin { async cmdRecords(login: string, args: string[]) { let records = []; - for (let record of this.records) { + for (const record of this.records) { records.push( { rank: record.rank, - nickname: escape(record.nickname), + nickname: escape(record.player.nickname ?? ""), login: record.login, - time: "$o" + formatTime(record.time), + time: "$o" + formatTime(record.time ?? 0), }); } const window = new RecordsWindow(login, this); @@ -110,23 +73,23 @@ export default class Records extends Plugin { } async syncRecords(mapUuid: string) { - if (!this.db) return; - const scores: any = this.db.select({ - login: Score.login, - nickname: Player.nickname, - time: Score.time, - avgTime: Score.avgTime, - totalFinishes: Score.totalFinishes, - checkpoints: Score.checkpoints, - createdAt: Score.createdAt, - updatedAt: Score.updatedAt, - }).from(Score).leftJoin(Player, eq(Score.login, Player.login)).where(eq(Score.mapUuid, mapUuid)).orderBy(asc(Score.time), asc(Score.updatedAt)).all(); - + const scores = await Score.findAll({ + where: { + mapUuid: mapUuid + }, + order: [ + // Will escape title and validate DESC against a list of valid direction parameters + ['time', 'ASC'], + ['updatedAt', 'ASC'], + ], + include: [Player], + }); + this.records = []; let rank = 1; - for (const score of scores) { + for (const score of scores) { score.rank = rank; - this.records.push(new Record().fromScore(score)); + this.records.push(score); rank += 1; } @@ -137,14 +100,28 @@ export default class Records extends Plugin { } async deleteRecord(login: string, data: any) { - if (!this.db) return; if (!tmc.admins.includes(login)) return; const msg = (`¤info¤Deleting map record for ¤white¤${data.nickname} ¤info¤(¤white¤${data.login}¤info¤)`); tmc.cli(msg); tmc.chat(msg, login); try { - await this.db?.delete(Score).where(and(eq(Score.login, data.login), eq(Score.mapUuid, this.currentMapUid))); + await Score.destroy({ + where: { + [Op.and]: { + login: data.login, + mapUuid: this.currentMapUid + } + } + }); + this.records = this.records.filter(r => r.login !== data.login); + + let rank = 1; + for (const score of this.records) { + score.rank = rank; + rank += 1; + } + tmc.server.emit("Plugin.Records.onRefresh", { records: clone(this.records), }); @@ -173,113 +150,113 @@ export default class Records extends Plugin { async onPlayerFinish(data: any) { const login = data[0]; - if (this.records.length == 0) { - let ranking = await this.getRankingsForLogin(data); - const newRecord = new Record().fromScore({ - login: login, - nickname: removeLinks(ranking.NickName), - time: ranking.BestTime, - avgTime: ranking.BestTime, - totalFinishes: 1, - checkpoints: ranking.BestCheckpoints.join(","), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - newRecord.rank = 1; - this.records.push(newRecord); - await this.db?.insert(Score).values({ - login: newRecord.login, - time: newRecord.time, - avgTime: newRecord.avgTime, - totalFinishes: newRecord.totalFinishes, - checkpoints: newRecord.checkpoints, - createdAt: newRecord.createdAt, - updatedAt: newRecord.updatedAt, - mapUuid: this.currentMapUid - }); - tmc.server.emit("Plugin.Records.onNewRecord", { - oldRecord: null, - record: clone(newRecord || {}), - records: clone(this.records) - }); - return; - } + try { + if (this.records.length == 0) { + let ranking = await this.getRankingsForLogin(data); + await Score.create({ + login: login, + time: ranking.BestTime, + checkpoints: ranking.BestCheckpoints.join(","), + mapUuid: this.currentMapUid, + }); + const newRecord = await Score.findOne( + { + where: { + [Op.and]: { + login: login, + mapUuid: this.currentMapUid + } + }, + include: Player + }); + newRecord.rank = 1; - const lastIndex = this.records.length > this.limit ? this.limit : this.records.length; - const lastRecord = this.records[lastIndex - 1]; + this.records.push(newRecord); + tmc.server.emit("Plugin.Records.onNewRecord", { + oldRecord: null, + record: clone(newRecord || {}), + records: clone(this.records) + }); + return; + } - let ranking = await this.getRankingsForLogin(data); + const lastIndex = this.records.length > this.limit ? this.limit : this.records.length; + const lastRecord = this.records[lastIndex - 1]; + + let ranking = await this.getRankingsForLogin(data); - if (lastIndex >= this.limit && ranking.BestTime >= lastRecord.time) return; - const time = ranking.BestTime; - const oldRecord = this.records.find(r => r.login === login); - if (oldRecord) { - if (ranking.BestTime >= oldRecord.time) return; - if (time < oldRecord.time) { - const newRecord = clone(oldRecord); - newRecord.nickname = removeLinks(ranking.NickName); - newRecord.avgTime = newRecord.avgTime + (time - newRecord.avgTime) / newRecord.totalFinishes; - newRecord.time = ranking.BestTime; - newRecord.checkpoints = ranking.BestCheckpoints.join(","); - newRecord.totalFinishes++; - newRecord.updatedAt = new Date().toISOString(); - await this.db?.update(Score).set({ - time: newRecord.time, - avgTime: newRecord.avgTime, - checkpoints: newRecord.checkpoints, - totalFinishes: newRecord.totalFinishes, - updatedAt: newRecord.updatedAt - }).where(and(eq(Score.login, login), eq(Score.mapUuid, this.currentMapUid))); - this.records[this.records.findIndex(r => r.login === login)] = newRecord; + if (lastIndex >= this.limit && lastRecord && ranking.BestTime >= lastRecord.time) return; + const time = ranking.BestTime; + const record = this.records.find(r => r.login === login); + let oldRecord = clone(record); + if (record) { + if (ranking.BestTime >= record.time) return; + if (time < record.time) { + record.update({ + time: ranking.BestTime, + checkpoints: ranking.BestCheckpoints.join(",") + }); + this.records[this.records.findIndex(r => r.login === login)] = record; + } + } else { + await Score.create({ + mapUuid: this.currentMapUid, + login: login, + time: ranking.BestTime, + checkpoints: ranking.BestCheckpoints.join(","), + }); + const newRecord = await Score.findOne( + { + where: { + [Op.and]: { + login: login, + mapUuid: this.currentMapUid + } + }, + include: Player + } + ); + this.records.push(newRecord); } - } else { - const newRecord = new Record().fromScore({ - login: login, - nickname: removeLinks(ranking.NickName), - time: ranking.BestTime, - avgTime: ranking.BestTime, - totalFinishes: 1, - checkpoints: ranking.BestCheckpoints.join(","), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }); - this.records.push(newRecord); - await this.db?.insert(Score).values({ - login: newRecord.login, - time: newRecord.time, - avgTime: newRecord.avgTime, - totalFinishes: newRecord.totalFinishes, - checkpoints: newRecord.checkpoints, - createdAt: newRecord.createdAt, - updatedAt: newRecord.updatedAt, - mapUuid: this.currentMapUid + // Sort records + this.records.sort((a, b) => { + + if (a.time === b.time) { + const str = a.updatedAt.toString(); + return str.localeCompare(b.updatedAt.toString()); + } + return a.time - b.time; }); - } - // Sort records - this.records.sort((a, b) => { - if (a.time === b.time) { - return a.updatedAt.localeCompare(b.updatedAt); - } - return a.time - b.time; - }); - // Update ranks - let newRecord = {}; - for (let i = 0; i < this.records.length; i++) { - this.records[i].rank = i + 1; - if (this.records[i].login === login) { - newRecord = this.records[i]; - } - if (i >= this.limit) { - tmc.cli(`Deleting record ${i} because it's out of limit.`); - await this.db?.delete(Score).where(and(eq(Score.login, this.records[i].login), eq(Score.mapUuid, this.currentMapUid))); + + // Update ranks + let outRecord = {}; + for (let i = 0; i < this.records.length; i++) { + this.records[i].rank = i + 1; + + if (this.records[i].login == login) { + outRecord = this.records[i]; + } + if (i >= this.limit) { + tmc.cli(`Deleting record ${i} because it's out of limit.`); + await Score.destroy({ + where: { + [Op.and]: { + login: this.records[i].login, + mapUuid: this.currentMapUid + } + } + }); + } } + + this.records = this.records.slice(0, this.limit); + tmc.server.emit("Plugin.Records.onUpdateRecord", { + oldRecord: oldRecord || {}, + record: clone(outRecord), + records: clone(this.records) + }); + } catch (e: any) { + console.log(e); } - this.records = this.records.slice(0, this.limit); - tmc.server.emit("Plugin.Records.onUpdateRecord", { - oldRecord: clone(oldRecord || {}), - record: clone(newRecord), - records: clone(this.records) - }); } - } \ No newline at end of file diff --git a/core/plugins/records/recordsWindow.ts b/core/plugins/records/recordsWindow.ts index d8de45f..4dbc792 100644 --- a/core/plugins/records/recordsWindow.ts +++ b/core/plugins/records/recordsWindow.ts @@ -1,6 +1,6 @@ -import ListWindow from "core/ui/listwindow.ts"; -import Records from "core/plugins/records/index.ts"; -import Confirm from "core/ui/confirm"; +import ListWindow from "../../ui/listwindow.ts"; +import Records from "../../plugins/records/index.ts"; +import Confirm from "../../ui/confirm"; export default class RecordsWindow extends ListWindow { app: Records; diff --git a/core/plugins/tmnf/coppers/index.ts b/core/plugins/tmnf/coppers/index.ts index 6944a19..8757378 100644 --- a/core/plugins/tmnf/coppers/index.ts +++ b/core/plugins/tmnf/coppers/index.ts @@ -1,4 +1,4 @@ -import Plugin from "core/plugins"; +import Plugin from "../../../plugins"; interface BillState { login: string; diff --git a/core/plugins/tmnf/dedimania/index.ts b/core/plugins/tmnf/dedimania/index.ts index 5d6b504..a5d0973 100644 --- a/core/plugins/tmnf/dedimania/index.ts +++ b/core/plugins/tmnf/dedimania/index.ts @@ -1,8 +1,8 @@ -import { Player } from 'core/playermanager'; +import { Player } from '../../../playermanager'; import Api from './api'; -import { clone, escape, formatTime } from 'core/utils'; -import ListWindow from 'core/ui/listwindow'; -import Plugin from 'core/plugins'; +import { clone, escape, formatTime } from '../../../utils'; +import ListWindow from '../../../ui/listwindow'; +import Plugin from '../../../plugins'; export interface DediRecord { Game?: string; @@ -116,9 +116,9 @@ export default class Dedimania extends Plugin { SrvIP: "127.0.0.1", SrvPort: "2350", XmlRpcPort: "5000", - NumPlayers: tmc.players.get().filter((pl: Player) => !pl.isSpectator).length, + NumPlayers: tmc.players.getAll().filter((pl: Player) => !pl.isSpectator).length, MaxPlayers: serverInfo.CurrentMaxPlayers, - NumSpectators: tmc.players.get().filter((pl: Player) => pl.isSpectator).length, + NumSpectators: tmc.players.getAll().filter((pl: Player) => pl.isSpectator).length, MaxSpectators: serverInfo.CurrentMaxSpectators, LadderMode: serverInfo.LadderMode, NextFiveUID: "", @@ -271,9 +271,9 @@ export default class Dedimania extends Plugin { SrvIP: "127.0.0.1", SrvPort: "2350", XmlRpcPort: "5000", - NumPlayers: tmc.players.get().filter((pl: Player) => !pl.isSpectator).length, + NumPlayers: tmc.players.getAll().filter((pl: Player) => !pl.isSpectator).length, MaxPlayers: serverInfo.CurrentMaxPlayers, - NumSpectators: tmc.players.get().filter((pl: Player) => pl.isSpectator).length, + NumSpectators: tmc.players.getAll().filter((pl: Player) => pl.isSpectator).length, MaxSpectators: serverInfo.CurrentMaxSpectators, LadderMode: serverInfo.LadderMode, NextFiveUID: "", @@ -288,7 +288,7 @@ export default class Dedimania extends Plugin { async getDedimaniaPlayers() { const out = []; - for (let player of tmc.players.get()) { + for (let player of tmc.players.getAll()) { out.push({ Login: player.login, NickName: player.nickname, diff --git a/core/plugins/tmnf/freezone/index.ts b/core/plugins/tmnf/freezone/index.ts index 9a4cf36..f4bf5b2 100644 --- a/core/plugins/tmnf/freezone/index.ts +++ b/core/plugins/tmnf/freezone/index.ts @@ -28,7 +28,7 @@ * SOFTWARE. */ -import Plugin from "core/plugins"; +import Plugin from "../../../plugins"; import http, { ClientRequest } from "http"; export default class Freezone extends Plugin { @@ -38,7 +38,7 @@ export default class Freezone extends Plugin { mlHash: string = "6f116833b419fe7cb9c912fdaefb774845f60e79" mlUrl: string = "ws.trackmania.com" mlVersion: string = "239" - heartbeatInterval: Timer | null = null; + heartbeatInterval: any | null = null; onLoad = async () => { if (!this.password) { diff --git a/core/plugins/tmnf/index.ts b/core/plugins/tmnf/index.ts index 2759440..572363b 100644 --- a/core/plugins/tmnf/index.ts +++ b/core/plugins/tmnf/index.ts @@ -1,4 +1,4 @@ -import Plugin from 'core/plugins'; +import Plugin from '../../plugins'; export default class TmnfPlugin extends Plugin { diff --git a/core/plugins/tmnf/talimit/index.ts b/core/plugins/tmnf/talimit/index.ts index 7e4860b..201dfa3 100644 --- a/core/plugins/tmnf/talimit/index.ts +++ b/core/plugins/tmnf/talimit/index.ts @@ -1,5 +1,5 @@ -import Widget from 'core/ui/widget'; -import Plugin from 'core/plugins'; +import Widget from '../../../ui/widget'; +import Plugin from '../../../plugins'; import tm from 'tm-essentials'; export default class TAlimitPlugin extends Plugin { @@ -9,7 +9,7 @@ export default class TAlimitPlugin extends Plugin { active: boolean = false; extend: boolean = false; widget: Widget | null = null; - intervalId: Timer | null = null; + intervalId: any | null = null; async onBeginRound() { this.startTime = Date.now(); diff --git a/core/plugins/tmnf/togglechat/index.ts b/core/plugins/tmnf/togglechat/index.ts index ea0eb59..7f9e992 100644 --- a/core/plugins/tmnf/togglechat/index.ts +++ b/core/plugins/tmnf/togglechat/index.ts @@ -1,5 +1,5 @@ -import Plugin from 'core/plugins'; -import Widget from 'core/ui/widget'; +import Plugin from '../../../plugins'; +import Widget from '../../../ui/widget'; export default class ToggleChat extends Plugin { static depends: string[] = ["game:TmForever"]; diff --git a/core/plugins/tmnf/ui/index.ts b/core/plugins/tmnf/ui/index.ts index def5503..5e85c54 100644 --- a/core/plugins/tmnf/ui/index.ts +++ b/core/plugins/tmnf/ui/index.ts @@ -1,4 +1,4 @@ -import Plugin from "core/plugins"; +import Plugin from "../../../plugins"; const environments = ['Stadium', 'Speed', 'Alpine', 'Bay', 'Coast', 'Island', 'Rally']; diff --git a/core/plugins/tmx/index.ts b/core/plugins/tmx/index.ts index 3b5c0d8..eecb050 100644 --- a/core/plugins/tmx/index.ts +++ b/core/plugins/tmx/index.ts @@ -1,4 +1,4 @@ -import Plugin from 'core/plugins'; +import Plugin from '../../plugins'; import fs from 'fs'; interface Map { @@ -39,6 +39,10 @@ export default class Tmx extends Plugin { async onLoad() { tmc.addCommand("//add", this.addMap.bind(this), "Add map from TMX"); tmc.addCommand("//addpack", this.addMapPack.bind(this), "Add map pack from TMX"); + tmc.addCommand("//cancelpack", () => { + tmc.chat("Admin cancelled the download!"); + this.cancelToken = true; + }, "Cancel pack download") } async onUnload() { @@ -80,7 +84,7 @@ export default class Tmx extends Plugin { tmc.chat("¤info¤To cancel: ¤cmd¤//addpack cancel", login); return; } - + if (params[0].toLowerCase() === "cancel") { this.cancelToken = true; tmc.chat("Map Pack download cancelled.", login); @@ -158,7 +162,12 @@ export default class Tmx extends Plugin { } await tmc.server.call("AddMap", filePath); await tmc.maps.syncMaplist(); - tmc.chat(`Added MapId ¤white¤${map.id} ¤info¤from ¤white¤${map.baseUrl}!`); + const info = await tmc.server.call("GetMapInfo", `${tmc.mapsPath}${filePath}`); await tmc.maps.syncMaplist(); + const author = info.AuthorNickname || info.Author || "n/a"; + tmc.chat(`¤info¤Map ¤white¤${info.Name} ¤info¤by ¤white¤${author} ¤info¤from ¤white¤${map.baseUrl}!`); + if (Object.keys(tmc.plugins).includes("maps")) { + await tmc.chatCmd.execute(login, `/addqueue ${info.UId}`); + } return; } catch (err: any) { tmc.chat(err, login); @@ -171,7 +180,12 @@ export default class Tmx extends Plugin { fs.writeFileSync(`${tmc.mapsPath}${filePath}`, Buffer.from(abuffer)); await tmc.server.call("AddMap", filePath); await tmc.maps.syncMaplist(); - tmc.chat(`Added MapId ¤white¤${map.id} ¤info¤from ¤white¤${map.baseUrl}!`); + const info = await tmc.server.call("GetMapInfo", `${tmc.mapsPath}${filePath}`); await tmc.maps.syncMaplist(); + const author = info.AuthorNickname || info.Author || "n/a"; + tmc.chat(`¤info¤Added map ¤white¤${info.Name} ¤info¤by ¤white¤${author} ¤info¤from ¤white¤${map.baseUrl}!`); + if (Object.keys(tmc.plugins).includes("maps")) { + await tmc.chatCmd.execute(login, `/addqueue ${info.UId}`); + } } async parseAndDownloadTrackPack(packId: string, login: string) { @@ -225,9 +239,9 @@ export default class Tmx extends Plugin { const res = await fetch(url, { keepalive: false }); const json: any = await res.json(); - tmc.chat("Processing Map Pack " + packId); + tmc.chat("Processing Map Pack ¤white¤" + packId); if (!json) { - tmc.chat(`Error while adding Pack ID ${packId}: ${res.statusText}`, login); + tmc.chat(`¤error¤Error while adding Pack ID ${packId}: ${res.statusText}`, login); } let results = json; if (tmc.game.Name === "TmForever") results = json.Results; @@ -236,13 +250,13 @@ export default class Tmx extends Plugin { try { let mapName = tmc.game.Name === "TmForever" ? data.TrackName : data.GbxMapName; let id = tmc.game.Name === "TmForever" ? data.TrackId : data.TrackID; - tmc.chat(`Downloading: ${mapName}`); + tmc.chat(`Downloading: ¤white¤${mapName}`); const map: Map = { id, baseUrl, site } await this.downloadMap(map, login); } catch (err: any) { tmc.chat(`¤error¤Error: ${err.message}`); } } - tmc.chat("All Done!"); + tmc.chat("¤white¤Done!"); } } \ No newline at end of file diff --git a/core/plugins/votes/index.ts b/core/plugins/votes/index.ts index 8ec6542..5d9153e 100644 --- a/core/plugins/votes/index.ts +++ b/core/plugins/votes/index.ts @@ -1,6 +1,6 @@ -import Plugin from 'core/plugins'; -import Widget from 'core/ui/widget'; -import { formatTime, processColorString, escape } from 'core/utils'; +import Plugin from '../../plugins'; +import Widget from '../../ui/widget'; +import { formatTime, processColorString, escape } from '../..//utils'; export class Vote { type: string; diff --git a/core/plugins/widgets/bestcps/index.ts b/core/plugins/widgets/bestcps/index.ts index 5063f6a..1883ddf 100644 --- a/core/plugins/widgets/bestcps/index.ts +++ b/core/plugins/widgets/bestcps/index.ts @@ -1,7 +1,7 @@ import tm from 'tm-essentials'; -import Plugin from 'core/plugins'; -import Widget from 'core/ui/widget'; -import { formatTime } from 'core/utils'; +import Plugin from '../../../plugins'; +import Widget from '../../../ui/widget'; +import { formatTime } from '../../../utils'; interface Time { nickname: string; @@ -48,6 +48,7 @@ export default class BestCps extends Plugin { const time = data[1]; const nb = data[2]; if (nb >= this.maxCp) return; + if (nb >= this.nbCheckpoints - 1) return; if (this.bestTimes[nb] && time < this.bestTimes[nb].time) { this.bestTimes[nb] = { nickname: (await tmc.getPlayer(login)).nickname, time: time, prettyTime: formatTime(time) }; await this.display(); diff --git a/core/plugins/widgets/checkpoints/index.ts b/core/plugins/widgets/checkpoints/index.ts index 7f4c116..e800d7d 100644 --- a/core/plugins/widgets/checkpoints/index.ts +++ b/core/plugins/widgets/checkpoints/index.ts @@ -1,6 +1,6 @@ -import type { Player } from "core/playermanager"; -import Plugin from "core/plugins"; -import Widget from "core/ui/widget"; +import type { Player } from "../../../playermanager"; +import Plugin from "../../../plugins"; +import Widget from "../../../ui/widget"; export default class Checkpoints extends Plugin { @@ -9,7 +9,7 @@ export default class Checkpoints extends Plugin { async onLoad() { tmc.server.addListener("Trackmania.BeginMap", this.onBeginMap, this); - tmc.server.addListener("Trackmania.EndRace", this.onHideWidget, this); + tmc.server.addListener("Trackmania.EndRace", this.onHideWidget, this); tmc.server.addListener("TMC.PlayerConnect", this.onPlayerConnect, this); tmc.server.addListener("TMC.PlayerDisconnect", this.onPlayerDisconnect, this); tmc.server.addListener("TMC.PlayerCheckpoint", this.onPlayerCheckpoint, this); @@ -40,7 +40,7 @@ export default class Checkpoints extends Plugin { widget.pos = { x: 0, y: -74 }; widget.size = { width: 20, height: 5 }; widget.data = { - totalCheckpoints: tmc.maps.currentMap?.NbCheckpoints || 0, + totalCheckpoints: (tmc.maps.currentMap?.NbCheckpoints || 0) - 1, currentCheckpoint: this.checkpointCounter[login] || 0, }; this.widgets[login] = widget; @@ -57,7 +57,7 @@ export default class Checkpoints extends Plugin { } async onHideWidget(data: any) { - const players = tmc.players.get(); + const players = tmc.players.getAll(); for (const player of players) { if (this.widgets[player.login]) { await this.widgets[player.login].hide(); @@ -67,7 +67,7 @@ export default class Checkpoints extends Plugin { async onBeginMap(data: any) { this.checkpointCounter = {}; - const players = tmc.players.get(); + const players = tmc.players.getAll(); for (const player of players) { this.checkpointCounter[player.login] = 0; await this.onPlayerConnect(player); @@ -76,12 +76,15 @@ export default class Checkpoints extends Plugin { async onPlayerCheckpoint(data: any) { this.checkpointCounter[data[0]] += 1; - await this.displayWidget(data[0]); + if (this.checkpointCounter[data[0]] < (tmc.maps.currentMap?.NbCheckpoints || 0)) { + await this.displayWidget(data[0]); + } + } async onPlayerFinish(data: any) { - // this.checkpointCounter[data[0]] = 0; - // await this.displayWidget(data[0]); + this.checkpointCounter[data[0]] = 0; + await this.displayWidget(data[0]); } async onPlayerGiveup(data: any) { @@ -95,7 +98,7 @@ export default class Checkpoints extends Plugin { await this.onPlayerConnect(player); } this.widgets[login].data = { - totalCheckpoints: tmc.maps.currentMap?.NbCheckpoints || 0, + totalCheckpoints: (tmc.maps.currentMap?.NbCheckpoints || 0) - 1, currentCheckpoint: this.checkpointCounter[login] || 0, }; await this.widgets[login].display(); diff --git a/core/plugins/widgets/dedimania/index.ts b/core/plugins/widgets/dedimania/index.ts index 2765ae3..4c2d748 100644 --- a/core/plugins/widgets/dedimania/index.ts +++ b/core/plugins/widgets/dedimania/index.ts @@ -1,6 +1,6 @@ -import Plugin from 'core/plugins'; -import Widget from 'core/ui/widget'; -import { formatTime, escape } from 'core/utils'; +import Plugin from '../../../plugins'; +import Widget from '../../../ui/widget'; +import { formatTime, escape } from '../../../utils'; export default class DedimaniaWidget extends Plugin { static depends: string[] = ["tmnf/dedimania"]; diff --git a/core/plugins/widgets/dedimania/widget.twig b/core/plugins/widgets/dedimania/widget.twig index 4775c26..66fb36a 100644 --- a/core/plugins/widgets/dedimania/widget.twig +++ b/core/plugins/widgets/dedimania/widget.twig @@ -3,7 +3,7 @@ {% block content %} {% for item in data.records %} -