From 0e20f7d4f93ddb2f268dafa21c6ea9dd820a1708 Mon Sep 17 00:00:00 2001 From: Nulled <63370961+Nul-led@users.noreply.github.com> Date: Mon, 21 Nov 2022 23:15:13 +0100 Subject: [PATCH] Implement custom commands (client console) loading (#63) * Update dma.js * add config for custom commands * implement custom commands & add docs * Update config.js * this should work * test (#77) * guardiaaaaaaan * tiny fixes? * fix the summoner again probably * fix bug in bosses and dominator (#64) * Update FallenOverlord.ts * Update Dominator.ts * fix bugs in bosses and dominator * mothership gamemode (#61) * Add files via upload * Add files via upload * Update Arena.ts * Update Game.ts * Update index.ts * Update Mothership.ts * Update Mothership.ts * Update Mothership.ts * Update Mothership.ts * Update Mothership.ts * Update Mothership.ts * Update Mothership.ts * Update index.ts * Update Mothership.ts * Update Mothership.ts * circle on rect more accurate * docs: fix license link (#65) * patch(#59): disable flags and movement on disconnection * patch some mothership stuff (#66) * patch some mothership stuff - removed a random bug - changed colors to accurate and also added color map - made mothership stop being possessed after 10 minutes - changed "hasFinished" prop to a new state in ArenaState in preparation for Domination and Tagmode * sort scoreboard by health * rework spawns Co-authored-by: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> * chore * annoying * undo deletion * drone ai doesn't care if inputs deleted * cleanup * cleanup (remove useless imports and code) * cleanup * fix: allow utf-8 in changelog (and all other content served) * fix domination once again * cleanup and bugs * remove useless stuff probably * Update Mothership.ts * fix: domination "peaceful" bases do not kill bases anymore (#67) * checks for pushFactor before deleting * fix: mothership implemented * patch some mothership stuff - removed a random bug - changed colors to accurate and also added color map - made mothership stop being possessed after 10 minutes - changed "hasFinished" prop to a new state in ArenaState in preparation for Domination and Tagmode * sort scoreboard by health * rework spawns * merge conflict + fix up mothership * fix barrels shooting from opposite team on first creation * Update TeamEntity.ts Co-authored-by: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> * fix: let non teamed players get broadcasted on death of mot Co-authored-by: ABC <79597906+ABCxFF@users.noreply.github.com> * feat: barrel addons and "swarm" (#69) * fix: double ai view range of swarm drones * ALLOW PUSH TO MAIN * add back license spaces Co-authored-by: ABC <79597906+ABCxFF@users.noreply.github.com> Co-authored-by: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Co-authored-by: ABCxFF <79597906+ABCxFF@users.noreply.github.com> Co-authored-by: Somka000 <51973243+Somka000@users.noreply.github.com> * Create Commands.ts * modularize possession of ais * add command parser * implement custom commands on the client * redo fix (#1 probably) :') * redo fix (2 :x) * implement other util clientside commands * add getTankByName func * send accessLevel to client * fix typo * fix Clientinputs for ai possessing * update endpoints - made /servers only serve running servers - added /commands * import commanddefs * fix next weird bug :d * Update loader.js * add semicolon * add config option for custom commands * implement execution prevention option for remote commands * endpoint /commands results in empty array if command execution is disabled * change to hasOwnProperty * use map instead of object, also verbose client logging Co-authored-by: c86ec23b-fef1-4979-b2fa-b9adc351b8cc <87239823+c86ec23b-fef1-4979-b2fa-b9adc351b8cc@users.noreply.github.com> Co-authored-by: ABCxFF <79597906+ABCxFF@users.noreply.github.com> Co-authored-by: Somka000 <51973243+Somka000@users.noreply.github.com> --- client/config.js | 91 +++++++++- client/dma.js | 48 ++++- client/loader.js | 337 +++++++++++++++++++++++++++++------ src/Client.ts | 110 +++++++----- src/Const/Commands.ts | 238 +++++++++++++++++++++++++ src/Const/TankDefinitions.ts | 4 + src/config.ts | 5 +- src/index.ts | 7 +- 8 files changed, 728 insertions(+), 112 deletions(-) create mode 100644 src/Const/Commands.ts diff --git a/client/config.js b/client/config.js index 1c246aa2..1f5bd74b 100644 --- a/client/config.js +++ b/client/config.js @@ -240,14 +240,16 @@ const MOD_CONFIG = { "loadVectorDone": 22, "loadChangelog": 447, "loadTankDefinitions": 277, - "getTankDefinition": 101 + "getTankDefinition": 101, + "findCommand": 496 }, "memory": { "gamemodeButtons": 113480, "changelog": 167328, "changelogLoaded": 168632, "tankDefinitions": 166572, - "tankDefinitionsCount": 166576 + "tankDefinitionsCount": 166576, + "commandList": 53064 }, "wasmFunctionHookOffset": { "gamemodeButtons": 33, @@ -274,9 +276,92 @@ const ADDON_MAP = { } }; +const CUSTOM_COMMANDS = [ + { + "id": "test", + "description": "Test command to check if custom commands are working, prints 'Hello World' to the console", + "callback": args => { // array of strings, you need to parse them yourself + console.log("Hello World"); + } + }, { + "id": "util_reload_servers", + "usage": "[?interval]", + "description": "Sets the interval in which gamemodes are reloaded automatically (milliseconds, 'never' or 'connect') or reloads once if no interval is given", + "callback": args => { + if(args[0]) { + const num = parseInt(args[0]); + if(isNaN(num)) { + switch(args[0]) { + case "never": + return Module.reloadServersInterval = -1; + case "connect": + return Module.reloadServersInterval = -2; + } + } + return Module.reloadServersInterval = num; + } + Game.reloadServers(); + } + }, { + "id": "util_reload_tanks", + "usage": "[?interval]", + "description": "Sets the interval in which tanks are reloaded automatically (milliseconds, 'never' or 'connect') or reloads once if no interval is given", + "callback": args => { + if(args[0]) { + const num = parseInt(args[0]); + if(isNaN(num)) { + switch(args[0]) { + case "never": + return Module.reloadTanksInterval = -1; + case "connect": + return Module.reloadTanksInterval = -2; + } + } + return Module.reloadTanksInterval = num; + } + Game.reloadTanks(); + } + }, { + "id": "util_reload_commands", + "usage": "[?interval]", + "description": "Sets the interval in which commands are reloaded automatically (milliseconds, 'never' or 'connect') or reloads once if no interval is given", + "callback": args => { + if(args[0]) { + const num = parseInt(args[0]); + if(isNaN(num)) { + switch(args[0]) { + case "never": + return Module.reloadCommandsInterval = -1; + case "connect": + return Module.reloadCommandsInterval = -2; + } + } + return Module.reloadCommandsInterval = num; + } + Game.reloadCommands(); + } + }, { + "id": "util_set_changelog", + "usage": "[line 1\\n] [line 2] ...", + "description": "Sets the changelog to the given text, remember to use \\n before and after each line", + "callback": args => { + Game.changeChangelog(args.join(' ').split("\\n")); + } + } +]; + +const COMMANDS_LOOKUP = { + "con_toggle": 52952, + "game_spawn": 52992, + "help": 49956, + "lb_reconnect": 50056, + "net_replace_color": 50152, + "net_replace_colors": 50192, + "ui_replace_colors": 49916 +}; + const WASM_TABLE = { "initial": 687, - "maximum": 687, "element": "anyfunc" }; diff --git a/client/dma.js b/client/dma.js index c3dba837..4cf64852 100644 --- a/client/dma.js +++ b/client/dma.js @@ -30,6 +30,48 @@ const setupDMAHelpers = () => { $.write(ptr + offset, type, value); } + $.List = class LinkedList { + constructor(ptr, type, typeSize) { + this.ptr = ptr; + this.type = type; + this.typeSize = typeSize; + } + + push(...entries) { + for(const entry of entries) this._append(this._end, entry); + this.length += entries.length; + } + + forEach(cb) { + let current = $(this.ptr); + while(current.i32) { + cb(current.i32); + current = current.$; + } + } + + get length() { + return $(this.ptr)[4].i32; + } + + set length(val) { + $.write(this.ptr + 4, "u32", val); + } + + get _end() { + let current = $(this.ptr); + while(current.i32) current = current.$; + return current.at; + } + + _append(last, entry) { + const ptr = Module.exports.malloc(this.typeSize); + $.write(ptr, this.type, entry); + $.write(last, "$", ptr); + } + } + + $.Vector = class Vector { constructor(ptr, type, typeSize) { this.ptr = ptr; @@ -56,11 +98,7 @@ const setupDMAHelpers = () => { Module.HEAPU8.subarray(this.start, this.maxCapacity).fill(0); } - delete() { - this._free(); - } - - _free() { + destroy() { Module.exports.free(this.start); this.start = 0; this.end = 0; diff --git a/client/loader.js b/client/loader.js index 7d755775..e8e51746 100644 --- a/client/loader.js +++ b/client/loader.js @@ -16,29 +16,65 @@ along with this program. If not, see */ +// make module globally accessable window.Module = {}; +// todolist Module.todo = []; +// todo status Module.status = null; + +// is the todo list done? Module.isRunning = false; + +// has the module been aborted? Module.isAborted = false; + +// exception name Module.exception = null; + +// function index for dynamic calling of the main func Module.mainFunc = null; + +// content contexts Module.cp5 = null; + +// client input window.input = null; + +// arenas Module.servers = null; + +// tanks Module.tankDefinitions = null; Module.tankDefinitionsTable = null; + +// commands +Module.executeCommandFunctionIndex = null; +Module.executionCallbackMap = {}; +Module.commandDefinitions = null; + +// name input Module.textInput = document.getElementById("textInput"); Module.textInputContainer = document.getElementById("textInputContainer"); +// permission level is sent to client in the accept packet +Module.permissionLevel = -1; + +// (polling) intervals, can be a number (ms), -1 aka never or -2 aka whenever a new connection is initiated +Module.reloadServersInterval = 60000; +Module.reloadTanksInterval = -1; +Module.reloadCommandsInterval = -2; + +// abort client Module.abort = cause => { Module.isAborted = true; Module.isRunning = false; throw new WebAssembly.RuntimeError(`abort(${cause})`); }; +// run ASMConst method, basically replaces a lot of "real wasm imports" Module.runASMConst = (code, sigPtr, argbuf) => { const args = []; let char; @@ -52,12 +88,14 @@ Module.runASMConst = (code, sigPtr, argbuf) => { return ASMConsts[ASM_CONSTS[code]].apply(null, args); }; +// initializing the looper Module.setLoop = func => { if(!Module.isRunning || Module.isAborted || Module.exception === "quit") return; Module.mainFunc = func; window.requestAnimationFrame(Module.loop); }; +// process todo Module.run = async () => { let args = []; while(Module.todo.length) { @@ -68,6 +106,7 @@ Module.run = async () => { } }; +// looper, 1 animation frame = 1 main call, except for stack unwinds Module.loop = () => { if(!Module.isRunning || Module.isAborted || Module.exception === "quit") return; switch(Module.exception) { @@ -82,55 +121,78 @@ Module.loop = () => { } }; +// exit runtime (no unwind, originally unwind would be catched here) Module.exit = status => { Module.exception = "quit"; Module.isRunning = false; throw `Stopped runtime with status ${status}`; }; +// read utf8 from memory Module.UTF8ToString = ptr => ptr ? new TextDecoder().decode(Module.HEAPU8.subarray(ptr, Module.HEAPU8.indexOf(0, ptr))) : ""; +// i/o write used for console, not fully understood Module.fdWrite = (stream, ptr, count, res) => { let out = 0; for(let i = 0; i < count; i++) out += Module.HEAP32[(ptr + (i * 8 + 4)) >> 2]; Module.HEAP32[res >> 2] = out; }; +// write utf8 to memory Module.allocateUTF8 = str => { if(!str) return 0; const encoded = new TextEncoder().encode(str); - const ptr = Module.exports.malloc(encoded.byteLength + 1); // stringNT + const ptr = Module.exports.malloc(encoded.byteLength + 1); // stringNT aka *char[] if(!ptr) return; Module.HEAPU8.set(encoded, ptr); Module.HEAPU8[ptr + encoded.byteLength] = 0; return ptr; }; +// Refreshes UI Components Module.loadGamemodeButtons = () => { - const vec = new $.Vector(MOD_CONFIG.memory.gamemodeButtons, 'struct', 28); - if(vec.start) vec.delete(); - vec.push(...Module.servers.map(server => ([{ offset: 0, type: 'cstr', value: server.gamemode }, { offset: 12, type: 'cstr', value: server.name }, { offset: 24, type: 'i32', value: 0 }]))); - Module.rawExports.loadVectorDone(MOD_CONFIG.memory.gamemodeButtons + 12); + const vec = new $.Vector(MOD_CONFIG.memory.gamemodeButtons, "struct", 28); + if(vec.start) vec.destroy(); // remove old arenas + // map server response to memory struct + vec.push(...Module.servers.map(server => ([ + { offset: 0, type: "cstr", value: server.gamemode }, + { offset: 12, type: "cstr", value: server.name }, + { offset: 24, type: "i32", value: 0 } + ]))); + // placeholders to prevent single/no gamemode bugs + const placeholderId = Module.servers.find(e => e.gamemode === 'ffa') ? 'survival' : 'ffa' + for(let i = 0; i < 2 - Module.servers.length; ++i) { + vec.push(...[[ + { offset: 0, type: "cstr", value: placeholderId }, + { offset: 12, type: "cstr", value: "Placeholder" }, + { offset: 24, type: "i32", value: 1 } + ]]); + } + Module.rawExports.loadVectorDone(MOD_CONFIG.memory.gamemodeButtons + 12); // not understood }; -Module.loadChangelog = (changelog=CHANGELOG) => { - const vec = new $.Vector(MOD_CONFIG.memory.changelog, 'cstr', 12); - if(vec.start) vec.delete(); - vec.push(...changelog); - $(MOD_CONFIG.memory.changelogLoaded).i8 = 1; +// Refreshes UI Components +Module.loadChangelog = (changelog) => { + const vec = new $.Vector(MOD_CONFIG.memory.changelog, "cstr", 12); + if(vec.start) vec.destroy(); // remove old changelog + vec.push(...(changelog || CHANGELOG)); // either load custom or default + $(MOD_CONFIG.memory.changelogLoaded).i8 = 1; // not understood }; +// Ignore Hashtable, instead read from custom table Module.getTankDefinition = tankId => { if(!Module.tankDefinitions) return 0; - if(!Module.tankDefinitionsTable) Module.loadTankDefinitions(); + if(!Module.tankDefinitionsTable) Module.loadTankDefinitions(); // load tankdefs dynmically when requested if(!Module.tankDefinitionsTable[tankId]) return 0; - return Module.tankDefinitionsTable[tankId] + 12; + return Module.tankDefinitionsTable[tankId] + 12; // 12 bytes for tankIds }; +Module.getCommand = cmdIdPtr => COMMANDS_LOOKUP[$(cmdIdPtr).cstr] || 0; + Module.loadTankDefinitions = () => { const writeTankDef = (ptr, tank) => { // Please note that this is not the full tank/barrel struct but just the portion needed for the client to function properly - const barrels = tank.barrels ? tank.barrels.map(barrel => { + const barrels = tank.barrels ? tank.barrels.map(barrel => { // barrel fields return [ { offset: 0, type: "f32", value: barrel.angle }, { offset: 4, type: "f32", value: barrel.delay }, @@ -145,7 +207,7 @@ Module.loadTankDefinitions = () => { ]; }) : []; - const fields = [ + const fields = [ // tankdef fields { offset: 4, type: "u32", value: tank.id }, { offset: 8, type: "u32", value: tank.id }, { offset: 12, type: "u32", value: tank.id }, @@ -163,11 +225,12 @@ Module.loadTankDefinitions = () => { $.writeStruct(ptr, fields); }; - Module.tankDefinitionsTable = new Array(Module.tankDefinitions.length).fill(0); + // TODO Rewrite with new $.List datastructure + Module.tankDefinitionsTable = new Array(Module.tankDefinitions.length).fill(0); // clear memory let lastPtr = MOD_CONFIG.memory.tankDefinitions; for(const tank of Module.tankDefinitions) { if(!tank) continue; - const ptr = Module.exports.malloc(244); + const ptr = Module.exports.malloc(244); // length of a tankdef Module.HEAPU8.subarray(ptr, ptr + 244).fill(0); $(lastPtr).i32 = ptr; writeTankDef(ptr, tank); @@ -175,17 +238,71 @@ Module.loadTankDefinitions = () => { lastPtr = ptr; } - $(MOD_CONFIG.memory.tankDefinitionsCount).i32 = Module.tankDefinitions.filter(e => Boolean(e)).length; + $(MOD_CONFIG.memory.tankDefinitionsCount).i32 = Module.tankDefinitions.filter(e => Boolean(e)).length; // tankId xor based off this +}; + +// Executes a command callback from a command context +Module.executeCommand = execCtx => { + const cmd = $(execCtx)[0].cstr; + const tokens = $(execCtx)[12].vector("cstr", 12); + + if(!cmd || !tokens.length) throw `Invalid execution context (ptr: ${execCtx}) received`; + if(typeof Module.executionCallbackMap[tokens[0]] !== "function") { + if(!Module.commandDefinitions.find(({ id }) => id === tokens[0])) { + throw `${Module.executionCallbackMap[tokens]} for command ${cmd} is an invalid callback`; + } + const encoder = new TextEncoder(); + return Game.socket.send(new Uint8Array([ + 6, + ...encoder.encode(tokens[0]), 0, + tokens.slice(1).length, + ...tokens.slice(1).flatMap(token => [...encoder.encode(token), 0]) + ])); + } + + // [id, ...args], we only need args + Module.executionCallbackMap[tokens[0]](tokens.slice(1)); +}; + +/* + Command object: { id, usage, description, callback } + The execute command function will not check for validity of arguments, you need to do that on your own +*/ +Module.loadCommands = (commands = CUSTOM_COMMANDS) => { + const cmdList = new $.List(MOD_CONFIG.memory.commandList, "struct", 24); + for(let { id, usage, description, callback, permissionLevel } of commands) { + if(COMMANDS_LOOKUP[id] || permissionLevel > Module.permissionLevel) continue; // ignore duplicates + + // allocate Command + const cmdPtr = Module.exports.malloc(40); + $.writeStruct(cmdPtr, [ + { offset: 0, type: "cstr", value: id }, + { offset: 12, type: "cstr", value: usage || "" }, + { offset: 24, type: "cstr", value: description || "" }, + { offset: 36, type: "u32", value: Module.executeCommandFunctionIndex } // we handle every custom command with the same function + ]); + + COMMANDS_LOOKUP[id] = cmdPtr; + if(callback) Module.executionCallbackMap[id] = callback; + + // allocate HashNode + cmdList.push([ + { offset: 0, type: "u32", value: 0 }, // next node + { offset: 4, type: "u32", value: 0 }, // hash + { offset: 8, type: "cstr", value: id }, // command id + { offset: 20, type: "$", value: cmdPtr } // command def ptr + ]); + } }; const wasmImports = { assertFail: (condition, filename, line, func) => Module.abort("Assertion failed: " + UTF8ToString(condition) + ", at: " + [filename ? UTF8ToString(filename) : "unknown filename", line, func ? UTF8ToString(func) : "unknown function"]), mapFile: () => -1, // unused - sysMunmap: (addr, len) => addr === -1 || !len ? -28 : 0, + sysMunmap: (addr, len) => addr === -1 || !len ? -28 : 0, // not really used abort: Module.abort, asmConstsDII: Module.runASMConst, asmConstsIII: Module.runASMConst, - exitLive: () => Module.exception = "unwind", // unwind + exitLive: () => Module.exception = "unwind", // unwind stack exitForce: () => Module.exit(1), // exit / quit getNow: () => performance.now(), memCopyBig: (dest, src, num) => { Module.HEAPU8.copyWithin(dest, src, src + num) }, // for large packets @@ -195,7 +312,7 @@ const wasmImports = { envGet: () => 0, // unused envSize: () => 0, // unused fdWrite: Module.fdWrite, // used for diep client console - roundF: d => d >= 0 ? Math.floor(d + 0.5) : Math.ceil(d - 0.5), + roundF: d => d >= 0 ? Math.floor(d + 0.5) : Math.ceil(d - 0.5), // no, default Math.round doesn't work :D timeString: () => 0, // unused wasmMemory: new WebAssembly.Memory(WASM_MEMORY), wasmTable: new WebAssembly.Table(WASM_TABLE) @@ -203,12 +320,14 @@ const wasmImports = { Module.todo.push([() => { Module.status = "PREPARE"; + // map imports to config Module.imports = { a: Object.fromEntries(Object.entries(WASM_IMPORTS).map(([key, name]) => [key, wasmImports[name]])) }; return []; }, false]); Module.todo.push([() => { Module.status = "FETCH"; + // fetch necessary info and build return [fetch(`${CDN}build_${BUILD}.wasm.wasm`).then(res => res.arrayBuffer()), fetch(`${API_URL}servers`).then(res => res.json()), fetch(`${API_URL}tanks`).then(res => res.json())]; }, true]); @@ -219,96 +338,150 @@ Module.todo.push([(dependency, servers, tanks) => { const parser = new WailParser(new Uint8Array(dependency)); + // original function, we want to modify these const originalVectorDone = parser.getFunctionIndex(MOD_CONFIG.wasmFunctions.loadVectorDone); const originalLoadChangelog = parser.getFunctionIndex(MOD_CONFIG.wasmFunctions.loadChangelog); const originalLoadGamemodeButtons = parser.getFunctionIndex(MOD_CONFIG.wasmFunctions.loadGamemodeButtons); const originalLoadTankDefs = parser.getFunctionIndex(MOD_CONFIG.wasmFunctions.loadTankDefinitions); const originalGetTankDef = parser.getFunctionIndex(MOD_CONFIG.wasmFunctions.getTankDefinition); + const originalFindCommand = parser.getFunctionIndex(MOD_CONFIG.wasmFunctions.findCommand); - const loadGamemodeButtons = parser.addImportEntry({ - moduleStr: "mods", - fieldStr: "loadGamemodeButtons", - kind: "func", - type: parser.addTypeEntry({ + // function types + const types = { + // void [] + vn: parser.addTypeEntry({ form: "func", params: [], returnType: null - }) - }); - - const loadChangelog = parser.addImportEntry({ - moduleStr: "mods", - fieldStr: "loadChangelog", - kind: "func", - type: parser.addTypeEntry({ + }), + // void [int] + vi: parser.addTypeEntry({ form: "func", - params: [], + params: ["i32"], returnType: null - }) - }); - - const getTankDefinition = parser.addImportEntry({ - moduleStr: "mods", - fieldStr: "getTankDefinition", - kind: "func", - type: parser.addTypeEntry({ + }), + // int [int] + ii: parser.addTypeEntry({ form: "func", params: ["i32"], returnType: "i32" }) - }); + } + + // custom imports + const imports = { + loadGamemodeButtons: parser.addImportEntry({ + moduleStr: "mods", + fieldStr: "loadGamemodeButtons", + kind: "func", + type: types.vn + }), + loadChangelog: parser.addImportEntry({ + moduleStr: "mods", + fieldStr: "loadChangelog", + kind: "func", + type: types.vn + }), + getTankDefinition: parser.addImportEntry({ + moduleStr: "mods", + fieldStr: "getTankDefinition", + kind: "func", + type: types.ii + }), + findCommand: parser.addImportEntry({ + moduleStr: "mods", + fieldStr: "findCommand", + kind: "func", + type: types.ii + }), + executeCommand: parser.addImportEntry({ + moduleStr: "mods", + fieldStr: "executeCommand", + kind: "func", + type: types.vi + }) + } + + // Modded imports, see above Module.imports.mods = { loadGamemodeButtons: Module.loadGamemodeButtons, loadChangelog: Module.loadChangelog, - getTankDefinition: Module.getTankDefinition + getTankDefinition: Module.getTankDefinition, + findCommand: Module.getCommand, + executeCommand: Module.executeCommand }; + // export to be able to add as a function table element + parser.addExportEntry(imports.executeCommand, { + fieldStr: "executeCommand", + kind: "func" + }); + + // not understood entirely parser.addExportEntry(originalVectorDone, { fieldStr: "loadVectorDone", kind: "func" }); + // parses & modifies code function by function parser.addCodeElementParser(null, function({ index, bytes }) { switch(index) { + // modify load changelog function case originalLoadChangelog.i32(): // we only need the part where it checks if the changelog is already loaded to avoid too many import calls return new Uint8Array([ ...bytes.subarray(0, MOD_CONFIG.wasmFunctionHookOffset.changelog), - OP_CALL, ...VarUint32ToArray(loadChangelog.i32()), + OP_CALL, ...VarUint32ToArray(imports.loadChangelog.i32()), OP_RETURN, ...bytes.subarray(MOD_CONFIG.wasmFunctionHookOffset.changelog) - ]); + ]); + // modify load gamemode buttons function case originalLoadGamemodeButtons.i32(): // we only need the part where it checks if the buttons are already loaded to avoid too many import calls return new Uint8Array([ ...bytes.subarray(0, MOD_CONFIG.wasmFunctionHookOffset.gamemodeButtons), - OP_CALL, ...VarUint32ToArray(loadGamemodeButtons.i32()), + OP_CALL, ...VarUint32ToArray(imports.loadGamemodeButtons.i32()), OP_RETURN, ...bytes.subarray(MOD_CONFIG.wasmFunctionHookOffset.gamemodeButtons) ]); + // overwrite get tankdef function case originalGetTankDef.i32(): // we modify this to call a js function which then returns the tank def ptr from a table return new Uint8Array([ OP_GET_LOCAL, 0, - OP_CALL, ...VarUint32ToArray(getTankDefinition.i32()), + OP_CALL, ...VarUint32ToArray(imports.getTankDefinition.i32()), OP_RETURN, OP_END ]); + // overwrite find command function + case originalFindCommand.i32(): + return new Uint8Array([ + OP_GET_LOCAL, 0, + OP_CALL, ...VarUint32ToArray(imports.findCommand.i32()), + OP_RETURN, + OP_END + ]); + // delete tankdefs loading function case originalLoadTankDefs.i32(): // we dont want this to run anymore because it will call the original tank wrapper function return new Uint8Array([ OP_END ]); + // no interesting index default: return false; } }); + // parse modded wasm parser.parse(); + // instantiate return [new Promise(resolve => WebAssembly.instantiate(parser.write(), Module.imports).then(res => resolve(res.instance), reason => Module.abort(reason)))]; }, true]); Module.todo.push([instance => { Module.status = "INITIALIZE"; + // Exports Module.exports = Object.fromEntries(Object.entries(instance.exports).map(([key, func]) => [WASM_EXPORTS[key], func])); Module.rawExports = instance.exports; + // Memory Module.memBuf = wasmImports.wasmMemory.buffer, Module.HEAPU8 = new Uint8Array(Module.memBuf); Module.HEAP8 = new Int8Array(Module.memBuf); @@ -320,41 +493,86 @@ Module.todo.push([instance => { Module.HEAPF64 = new Float64Array(Module.memBuf); Module.HEAPU64 = new BigUint64Array(Module.memBuf); Module.HEAP64 = new BigInt64Array(Module.memBuf); + // Cp5 Contexts Module.cp5 = { contexts: [], images: [], sockets: [], patterns: [] }; + // window.input & misc, see input.js window.setupInput(); + // Diep Memory Analyzer, see dma.js window.setupDMA(); return []; }, false]); Module.todo.push([() => { window.Game = { + // refetches servers & resets gamemode buttons reloadServers: async () => { Module.servers = await fetch(`${API_URL}servers`).then(res => res.json()); Module.loadGamemodeButtons(); }, + // refetches tankdefs & resets them reloadTanks: async () => { Module.tankDefinitions = await fetch(`${API_URL}tanks`).then(res => res.json()); for(const tankDef of Module.tankDefinitionsTable) Module.exports.free(tankDef); Module.loadTankDefinitions(); }, + reloadCommands: async () => { + Module.commandDefinitions = await fetch(`${API_URL}commands`).then(res => res.json()); + Module.loadCommands(Module.commandDefinitions); // remote + Module.loadCommands(); // local + }, + // sets changelog (input: [...""]) changeChangelog: (lines) => Module.loadChangelog(lines), - socket: null, + // main socket, see also Module.cp5.sockets[0] + get socket() { + return Module.cp5.sockets[0]; + }, + // executes spawn command spawn: name => window.input.execute(`game_spawn ${name}`), + // executes reconnect command reconnect: () => window.input.execute(`lb_reconnect`) }; + + // custom commands + Module.executeCommandFunctionIndex = Module.imports.a.table.grow(1); + Module.imports.a.table.set(Module.executeCommandFunctionIndex, Module.rawExports.executeCommand); Module.status = "START"; + // emscripten requirements Module.HEAP32[DYNAMIC_TOP_PTR >> 2] = DYNAMIC_BASE; Module.isRunning = true; Module.exports.wasmCallCtors(); Module.exports.main(); + + + const reloadServersInterval = () => setTimeout(() => { + reloadServersInterval(); + if(Module.reloadServersInterval < 0) return; + Game.reloadServers(); + }, Module.reloadServersInterval); + reloadServersInterval(); + + const reloadTanksInterval = () => setTimeout(() => { + reloadTanksInterval(); + if(Module.reloadCommandsInterval < 0) return; + Game.reloadTanks(); + }, Module.reloadTanksInterval); + reloadTanksInterval(); + + const reloadCommandsInterval = () => setTimeout(() => { + reloadCommandsInterval(); + if(Module.reloadCommandsInterval < 0) return; + Game.reloadCommands(); + }, Module.reloadCommandsInterval); + reloadCommandsInterval(); }, false]); + +// Part of the original emscripten bootstrap class ASMConsts { static createCanvasCtxWithAlpha(canvasId, alpha) { const canvas = document.getElementById(Module.UTF8ToString(canvasId)); @@ -697,7 +915,8 @@ class ASMConsts { } static setLocation(newLocation) { - window.localStorage = Module.UTF8ToString(newLocation); + // open in new tab instead + window.open(Module.UTF8ToString(newLocation)); } static contextDrawImage(ctxId, imgId) { @@ -905,7 +1124,7 @@ class ASMConsts { static createWebSocket(url) { url = Module.UTF8ToString(url); - if (url.split('.').length === 4) url = `ws${location.protocol.slice(4)}//${location.host}/game/${url.slice(url.indexOf("//") + 2, url.indexOf('.'))}`; + if (url.split(".").length === 4) url = `ws${location.protocol.slice(4)}//${location.host}/game/${url.slice(url.indexOf("//") + 2, url.indexOf("."))}`; else if (url.endsWith(":443")) url = `ws${location.protocol.slice(4)}//${location.host}/game/${url.slice(url.indexOf("//") + 2, url.length - 4)}` else return prompt("Error loading into game. Take a picture of this then send to our support server (github.com/ABCxFF/diepcustom)", url); @@ -926,6 +1145,16 @@ class ASMConsts { }; ws.onmessage = function(e) { const view = new Uint8Array(e.data); + if(view[0] === 7) { + let out = 0, i = 0, at = 1; + while(view[at] & 0x80) { + out |= (view[at++] & 0x7f) << i; + i += 7; + } + out |= (view[at++] & 0x7f) << i; + Module.permissionLevel = (0 - (out & 1)) ^ (out >>> 1); + window.Game.reloadCommands(); + } const ptr = Module.exports.malloc(view.length); Module.HEAP8.set(view, ptr); ws.events.push([1, ptr, view.length]); @@ -937,8 +1166,12 @@ class ASMConsts { Module.cp5.sockets[i] = ws; return i; } + + if(Module.reloadServersInterval === -2) Game.reloadServers(); + if(Module.reloadTanksInterval === -2) Game.reloadTanks(); + if(Module.reloadCommandsInterval === -2) Game.reloadCommands(); + Module.cp5.sockets.push(ws); - window.Game.socket = ws; return Module.cp5.sockets.length - 1; } diff --git a/src/Client.ts b/src/Client.ts index e02f34d2..8fb87b46 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -39,6 +39,7 @@ import { Entity, EntityStateFlags } from "./Native/Entity"; import { CameraFlags, ClientBound, InputFlags, NametagFlags, ServerBound, Stat, StatCount, Tank } from "./Const/Enums"; import { AI, AIState, Inputs } from "./Entity/AI"; import AbstractBoss from "./Entity/Boss/AbstractBoss"; +import { executeCommand } from "./Const/Commands"; /** XORed onto the tank id in the Tank Upgrade packet. */ const TANK_XOR = config.magicNum % TankCount; @@ -246,7 +247,7 @@ export default class Client { } // Finish handshake - this.write().u8(ClientBound.Accept).send(); + this.write().u8(ClientBound.Accept).vi(this.accessLevel).send(); this.write().u8(ClientBound.ServerInfo).stringNT(this.game.gamemode).stringNT(config.host).send(); this.write().u8(ClientBound.PlayerCount).vu(GameServer.globalPlayerCount).send(); this.camera = new Camera(this.game, this); @@ -460,54 +461,8 @@ export default class Client { }); for (let i = 0; i < AIs.length; ++i) { - if (AIs[i].state !== AIState.possessed && ((!AIs[i].isTaken && AIs[i].owner.relations.values.team === camera.relations.values.team)|| this.accessLevel === config.AccessLevel.FullAccess)) { - // if (AIs[i].state !== AIState.possessed && (AIs[i].owner.relations.values.team === camera.relations.values.team|| this.accessLevel === config.AccessLevel.FullAccess)) { - const ai = AIs[i]; - - this.inputs.deleted = true; - ai.inputs = this.inputs = new ClientInputs(this); - this.inputs.isPossessing = true; - ai.isTaken = true; - ai.state = AIState.possessed; - - // Silly workaround to change color of player when needed - if (camera.camera.values.player instanceof ObjectEntity) camera.camera.values.player.state |= camera.camera.values.player.style.state.color = 1; - - camera.camera.tankOverride = ai.owner.name?.values.name || ""; - - camera.camera.tank = 53; - - // AI stats, confirmed by Mounted Turret videos - for (let i = 0; i < StatCount; ++i) camera.camera.statLevels[i as Stat] = 0; - for (let i = 0; i < StatCount; ++i) camera.camera.statLimits[i as Stat] = 7; - for (let i = 0; i < StatCount; ++i) camera.camera.statNames[i as Stat] = ""; - - - camera.camera.killedBy = ""; - camera.camera.player = ai.owner; - camera.camera.movementSpeed = ai.movementSpeed; - - if (ai.owner instanceof TankBody) { - // If its a TankBody, set the stats, level, and tank to that of the TankBody - camera.camera.tank = ai.owner.cameraEntity.camera.values.tank; - camera.setLevel(ai.owner.cameraEntity.camera.values.level); - - for (let i = 0; i < StatCount; ++i) camera.camera.statLevels[i as Stat] = ai.owner.cameraEntity.camera.statLevels.values[i]; - for (let i = 0; i < StatCount; ++i) camera.camera.statLimits[i as Stat] = ai.owner.cameraEntity.camera.statLimits.values[i]; - for (let i = 0; i < StatCount; ++i) camera.camera.statNames[i as Stat] = ai.owner.cameraEntity.camera.statNames.values[i]; - - camera.camera.FOV = 0.35; - } else if (ai.owner instanceof AbstractBoss) { - camera.setLevel(75); - camera.camera.FOV = 0.25; - } else { - camera.setLevel(30); - // this.camera.movementSpeed = 0; - } - - camera.camera.statsAvailable = 0; - camera.camera.scorebar = 0; - + if (((!AIs[i].isTaken && AIs[i].owner.relations.values.team === camera.relations.values.team) || this.accessLevel === config.AccessLevel.FullAccess)) { + if(!this.possess(AIs[i])) continue; this.notify("Press H to surrender control of your tank", 0x000000, 5000); return; } @@ -520,12 +475,69 @@ export default class Client { return; } case ServerBound.TCPInit: + if(!config.enableCommands) return; + const cmd = r.stringNT(); + const argsLength = r.u8(); + const args: string[] = []; + for(let i = 0; i < argsLength; ++i) { + args.push(r.stringNT()); + } + executeCommand(this, cmd, args); return; default: util.log("Suspicious activies have been evaded") return this.ban(); } } + + /** Attempts possession of an AI */ + public possess(ai: AI) { + if (!this.camera?.camera || ai.state === AIState.possessed) return false; + + this.inputs.deleted = true; + ai.inputs = this.inputs = new ClientInputs(this); + this.inputs.isPossessing = true; + ai.isTaken = true; + ai.state = AIState.possessed; + + // Silly workaround to change color of player when needed + if (this.camera?.camera.values.player instanceof ObjectEntity) this.camera.camera.values.player.state |= this.camera.camera.values.player.style.state.color = 1; + + this.camera.camera.tankOverride = ai.owner.name?.values.name || ""; + + this.camera.camera.tank = 53; + + // AI stats, confirmed by Mounted Turret videos + for (let i = 0; i < StatCount; ++i) this.camera.camera.statLevels[i as Stat] = 0; + for (let i = 0; i < StatCount; ++i) this.camera.camera.statLimits[i as Stat] = 7; + for (let i = 0; i < StatCount; ++i) this.camera.camera.statNames[i as Stat] = ""; + + + this.camera.camera.killedBy = ""; + this.camera.camera.player = ai.owner; + this.camera.camera.movementSpeed = ai.movementSpeed; + + if (ai.owner instanceof TankBody) { + // If its a TankBody, set the stats, level, and tank to that of the TankBody + this.camera.camera.tank = ai.owner.cameraEntity.camera.values.tank; + this.camera.setLevel(ai.owner.cameraEntity.camera.values.level); + + for (let i = 0; i < StatCount; ++i) this.camera.camera.statLevels[i as Stat] = ai.owner.cameraEntity.camera.statLevels.values[i]; + for (let i = 0; i < StatCount; ++i) this.camera.camera.statLimits[i as Stat] = ai.owner.cameraEntity.camera.statLimits.values[i]; + for (let i = 0; i < StatCount; ++i) this.camera.camera.statNames[i as Stat] = ai.owner.cameraEntity.camera.statNames.values[i]; + + this.camera.camera.FOV = 0.35; + } else if (ai.owner instanceof AbstractBoss) { + this.camera.setLevel(75); + this.camera.camera.FOV = 0.25; + } else { + this.camera.setLevel(30); + } + + this.camera.camera.statsAvailable = 0; + this.camera.camera.scorebar = 0; + return true; + } /** Sends a notification packet to the client. */ public notify(text: string, color = 0x000000, time = 4000, id = "") { diff --git a/src/Const/Commands.ts b/src/Const/Commands.ts new file mode 100644 index 00000000..c6dbc9e1 --- /dev/null +++ b/src/Const/Commands.ts @@ -0,0 +1,238 @@ +import Client from "../Client" +import { AccessLevel } from "../config"; +import AbstractBoss from "../Entity/Boss/AbstractBoss"; +import Defender from "../Entity/Boss/Defender"; +import FallenBooster from "../Entity/Boss/FallenBooster"; +import FallenOverlord from "../Entity/Boss/FallenOverlord"; +import Guardian from "../Entity/Boss/Guardian"; +import Summoner from "../Entity/Boss/Summoner"; +import LivingEntity from "../Entity/Live"; +import ArenaCloser from "../Entity/Misc/ArenaCloser"; +import FallenAC from "../Entity/Misc/Boss/FallenAC"; +import FallenSpike from "../Entity/Misc/Boss/FallenSpike"; +import Dominator from "../Entity/Misc/Dominator"; +import AbstractShape from "../Entity/Shape/AbstractShape"; +import Crasher from "../Entity/Shape/Crasher"; +import Pentagon from "../Entity/Shape/Pentagon"; +import Square from "../Entity/Shape/Square"; +import Triangle from "../Entity/Shape/Triangle"; +import AutoTurret from "../Entity/Tank/AutoTurret"; +import Bullet from "../Entity/Tank/Projectile/Bullet"; +import TankBody from "../Entity/Tank/TankBody"; +import { Entity, EntityStateFlags } from "../Native/Entity"; +import { saveToVLog } from "../util"; +import { StyleFlags } from "./Enums"; +import { getTankByName } from "./TankDefinitions" + +export enum CommandID { + gameSetTank = "game_set_tank", + gameSetLevel = "game_set_level", + gameSetScore = "game_set_score", + gameTeleport = "game_teleport", + gameClaim = "game_claim", + adminGodmode = "admin_godmode", + adminSummon= "admin_summon", + adminKillAll = "admin_kill_all", + adminKillEntity = "admin_kill_entity", + adminCloseArena = "admin_close_arena" +} + +export interface CommandDefinition { + id: CommandID, + usage?: string, + description?: string, + permissionLevel: AccessLevel, +} + +export interface CommandCallback { + (client: Client, ...args: string[]): void +} + +export const commandDefinitions = { + game_set_tank: { + id: CommandID.gameSetTank, + usage: "[tank]", + description: "Changes your tank to the given class", + permissionLevel: AccessLevel.BetaAccess + }, + game_set_level: { + id: CommandID.gameSetLevel, + usage: "[level]", + description: "Changes your level to the given whole number", + permissionLevel: AccessLevel.BetaAccess + }, + game_set_score: { + id: CommandID.gameSetScore, + usage: "[score]", + description: "Changes your score to the given whole number", + permissionLevel: AccessLevel.BetaAccess + }, + game_teleport: { + id: CommandID.gameTeleport, + usage: "[x] [y]", + description: "Teleports you to the given position", + permissionLevel: AccessLevel.BetaAccess + }, + game_claim: { + id: CommandID.gameClaim, + usage: "[entityName]", + description: "Attempts claiming an entity of the given type", + permissionLevel: AccessLevel.BetaAccess + }, + admin_godmode: { + id: CommandID.adminGodmode, + description: "Toggles godmode", + permissionLevel: AccessLevel.FullAccess + }, + admin_summon: { + id: CommandID.adminSummon, + usage: "[entityName] [?count] [?x] [?y]", + description: "Spawns entities at a certain location", + permissionLevel: AccessLevel.FullAccess + }, + admin_kill_all: { + id: CommandID.adminKillAll, + description: "Kills all entities in the arena", + permissionLevel: AccessLevel.FullAccess + }, + admin_kill_entity: { + id: CommandID.adminKillEntity, + usage: "[entityName]", + description: "Kills all entities of the given type (might include self)", + permissionLevel: AccessLevel.FullAccess + }, + admin_close_arena: { + id: CommandID.adminCloseArena, + description: "Closes the current arena", + permissionLevel: AccessLevel.FullAccess + } +} as Record + +export const commandCallbacks = { + game_set_tank: (client: Client, tankNameArg: string) => { + const tankDef = getTankByName(tankNameArg); + const player = client.camera?.camera.player; + if (!tankDef || !Entity.exists(player) || !(player instanceof TankBody)) return; + player.setTank(tankDef.id); + }, + game_set_level: (client: Client, levelArg: string) => { + const level = parseInt(levelArg); + const player = client.camera?.camera.player; + if (isNaN(level) || !Entity.exists(player) || !(player instanceof TankBody)) return; + client.camera?.setLevel(level); + }, + game_set_score: (client: Client, scoreArg: string) => { + const score = parseInt(scoreArg); + const camera = client.camera?.camera; + const player = client.camera?.camera.player; + if (isNaN(score) || score > Number.MAX_SAFE_INTEGER || score < Number.MIN_SAFE_INTEGER || !Entity.exists(player) || !(player instanceof TankBody) || !camera) return; + camera.scorebar = score; + }, + game_teleport: (client: Client, xArg: string, yArg: string) => { + const x = parseInt(xArg); + const y = parseInt(yArg); + const player = client.camera?.camera.player; + if (isNaN(x) || isNaN(y) || !Entity.exists(player) || !(player instanceof TankBody)) return; + player.position.x = x; + player.position.y = y; + player.setVelocity(0, 0); + player.state |= EntityStateFlags.needsCreate | EntityStateFlags.needsDelete; + }, + game_claim: (client: Client, entityArg: string) => { + const TEntity = new Map([ + ["ArenaCloser", ArenaCloser], + ["Dominator", Dominator], + ["Shape", AbstractShape], + ["Boss", AbstractBoss], + ["AutoTurret", AutoTurret] + ]).get(entityArg); + + if (!TEntity || !client.camera?.game.entities.AIs.length) return; + + const AIs = Array.from(client.camera.game.entities.AIs); + for (let i = 0; i < AIs.length; ++i) { + if (!(AIs[i].owner instanceof TEntity)) continue; + client.possess(AIs[i]); + return; + } + }, + admin_godmode: (client: Client) => { + if(client.camera?.camera.player?.style?.styleFlags) { + if(client.camera.camera.player.style.styleFlags & StyleFlags.invincibility) { + client.camera.camera.player.style.styleFlags ^= StyleFlags.invincibility; + } else { + client.camera.camera.player.style.styleFlags |= StyleFlags.invincibility; + } + } + }, + admin_summon: (client: Client, entityArg: string, countArg?: string, xArg?: string, yArg?: string) => { + const count = countArg ? parseInt(countArg) : 1; + const x = parseInt(xArg || ""); + const y = parseInt(yArg || ""); + const game = client.camera?.game; + const TEntity = new Map([ + ["Defender", Defender], + ["Summoner", Summoner], + ["Guardian", Guardian], + ["FallenOverlord", FallenOverlord], + ["FallenBooster", FallenBooster], + ["FallenAC", FallenAC], + ["FallenSpike", FallenSpike], + ["ArenaCloser", ArenaCloser], + ["Crasher", Crasher], + ["Pentagon", Pentagon], + ["Square", Square], + ["Triangle", Triangle] + ])[entityArg]; + + if (isNaN(count) || count < 0 || !game || !TEntity) return; + + for (let i = 0; i < count; ++i) { + const boss = new TEntity(game); + if (!isNaN(x) && !isNaN(y)) { + boss.position.x = x; + boss.position.y = y; + } + } + }, + admin_kill_all: (client: Client) => { + const game = client.camera?.game; + if(!game) return; + for (let id = 0; id <= game.entities.lastId; ++id) { + const entity = game.entities.inner[id]; + if (Entity.exists(entity) && entity instanceof LivingEntity && entity !== client.camera?.camera.player) entity.health.health = 0; + } + }, + admin_close_arena: (client: Client) => { + client?.camera?.game.arena.close(); + }, + admin_kill_entity: (client: Client, entityArg: string) => { + const TEntity = new Map([ + ["ArenaCloser", ArenaCloser], + ["Dominator", Dominator], + ["Bullet", Bullet], + ["Tank", TankBody], + ["Shape", AbstractShape], + ["Boss", AbstractBoss] + ])[entityArg]; + const game = client.camera?.game; + if (!TEntity || !game) return; + + for (let id = 0; id <= game.entities.lastId; ++id) { + const entity = game.entities.inner[id]; + if (Entity.exists(entity) && entity instanceof TEntity) entity.health.health = 0; + } + } +} as Record + +export const executeCommand = (client: Client, cmd: string, args: string[]) => { + if (!commandDefinitions.hasOwnProperty(cmd) || !commandCallbacks.hasOwnProperty(cmd)) { + return saveToVLog(`${client.toString()} tried to run the invalid command ${cmd}`); + } + + if (client.accessLevel < commandDefinitions[cmd as CommandID].permissionLevel) { + return saveToVLog(`${client.toString()} tried to run the command ${cmd} with a permission that was too low`); + } + + commandCallbacks[cmd as CommandID](client, ...args); +} diff --git a/src/Const/TankDefinitions.ts b/src/Const/TankDefinitions.ts index 7b867a42..5885c1b2 100644 --- a/src/Const/TankDefinitions.ts +++ b/src/Const/TankDefinitions.ts @@ -6760,3 +6760,7 @@ export const TankCount = TankDefinitions.reduce((a, b) => b ? a + 1 : a, 0); export const getTankById = function (id: number): TankDefinition | null { return (id < 0 ? DevTankDefinitions[~id] : TankDefinitions[id]) || null; } + +export const getTankByName = function (tankName: string): TankDefinition | null { + return TankDefinitions.find(tank => tank && tank.name === tankName) || DevTankDefinitions.find(tank => tank && tank.name === tankName) || null; +} diff --git a/src/config.ts b/src/config.ts index 719c38f8..03703df4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,7 +47,10 @@ export const mode: string = process.env.NODE_ENV || "development"; export const enableApi: boolean = true; /** Rest API location (root of all other endpoints), ignored if enableApi is false */ -export const apiLocation: string = "api" +export const apiLocation: string = "api"; + +/** Allows execution of custom commands */ +export const enableCommands: boolean = true; /** Is hosting a client */ export const enableClient: boolean = true; diff --git a/src/index.ts b/src/index.ts index 936700fe..bebb2832 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,7 @@ import * as util from "./util"; import GameServer from "./Game"; import auth from "./Auth"; import TankDefinitions from "./Const/TankDefinitions"; +import { commandDefinitions } from "./Const/Commands"; const PORT = config.serverPort; const ENABLE_API = config.enableApi && config.apiLocation; @@ -53,11 +54,13 @@ const server = http.createServer((req, res) => { return res.end(JSON.stringify(TankDefinitions)); case "/servers": res.writeHead(200); - return res.end(JSON.stringify(games.map(({ gamemode, name }) => ({ gamemode, name })))); + return res.end(JSON.stringify(games.filter(({ running }) => running).map(({ gamemode, name }) => ({ gamemode, name })))); + case "/commands": + res.writeHead(200); + return res.end(JSON.stringify(config.enableCommands ? Object.values(commandDefinitions) : [])); } } - if (ENABLE_CLIENT) { let file: string | null = null; let contentType = "text/html"