openvibers/source.openvibe.games Commits
Add GMod-style JavaScript hook system
Changes
nnew file mode 100644@@@ -0,0 +1,209 @@++# OpenVibe JavaScript Scripting++++OpenVibe embeds QuickJS in the Source server DLL and exposes a small++GMod-style hook/gamemode API.++++## File Layout++++```text++game/openvibe.games/js/++ core/++ hook.js++ gamemode.js++ command.js++ timer.js++ bridge.js++ gamemodes/++ base/server.js++ hub/server.js++ prophunt/server.js++ deathrun/server.js++ fortwars/server.js++ traitortown/server.js++```++++The server loads core files first, then `base/server.js`, then the active mode:++++```text++js/gamemodes/<ov_mode>/server.js++```++++## Hook API++++Use the GMod-shaped API:++++```js++hook.Add("PlayerSpawn", "MyAddon.SpawnMessage", function (ply) {++ ply.chat("Spawn hook from JS.");++});++++hook.Remove("PlayerSpawn", "MyAddon.SpawnMessage");++++const result = hook.Run("SomeHook", arg1, arg2);++```++++Lowercase aliases also exist:++++```js++hook.add("PlayerSpawn", "id", fn);++hook.remove("PlayerSpawn", "id");++hook.run("PlayerSpawn", ply);++```++++Execution order:++++1. Registered hooks run in insertion order.++2. The first hook that returns anything other than `undefined` wins.++3. If no hook returns a value, the active `GM` method runs.++++This mirrors the GMod pattern where addon hooks can override gamemode behavior.++++## Gamemode API++++`base/server.js` defines the base `GM`. Active modes inherit from it:++++```js++(function () {++ const GM = {++ mode: "deathrun",++ name: "OpenVibe Deathrun",++++ Initialize() {++ OV.log("Deathrun initialized.");++ },++++ PlayerInitialSpawn(ply) {++ ply.chat("Welcome to Deathrun.");++ }++ };++++ gamemode.set(GM);++})();++```++++Globals:++++```js++GM++GAMEMODE++gamemode.get()++gamemode.getBase()++gamemode.call("HookName", ...args)++```++++## Events Exposed Today++++The native server bridge currently fires:++++```text++Initialize++MapInitialize(mapName)++Think++PlayerInitialSpawn(ply)++PlayerSpawn(ply)++PlayerDeath(victim, attacker)++PlayerDisconnected(ply)++PlayerSay(ply, text)++ConsoleCommand(text)++Shutdown++```++++## Commands++++Chat commands are routed through the `PlayerSay` hook.++++```js++command.Add("where", "Show mode and map", function ({ ply, reply }) {++ reply(ply, `Mode=${OV.getMode()} map=${OV.getMapName()}`);++ return false;++});++```++++Players type:++++```text++!where++```++++Returning `false` blocks normal chat, matching the current C++ bridge behavior.++++Console commands can be sent from SRCDS:++++```text++ov_js_cmd where++```++++## Timers++++Timers are advanced from the `Think` hook:++++```js++timer.simple(3, function () {++ OV.broadcast("Three seconds passed.");++});++++timer.create("round_tick", 1, 0, function (count) {++ OV.log(`Round tick ${count}`);++});++```++++`reps=0` means repeat forever.++++## Native Bridge++++Available server APIs:++++```js++OV.log(msg)++OV.warn(msg)++OV.error(msg)++OV.getMode()++OV.getMapName()++OV.time()++OV.players()++OV.playerByUserId(userId)++OV.broadcast(msg)++OV.serverCommand(cmd)++OV.fireHook(name, ...args)++```++++Player wrapper APIs:++++```js++ply.userId()++ply.entIndex()++ply.steamId()++ply.name()++ply.health()++ply.setHealth(value)++ply.team()++ply.setTeam(team)++ply.chat(msg)++ply.runCommand("ov_prophunt_disguise crate")++```++++Server and player command execution is allowlisted to `ov_*` commands.++++## Reloading++++In SRCDS:++++```text++ov_js_status++ov_js_reload++ov_js_fire Initialize++ov_js_cmd smoke++```++++## Next Native Hooks To Add++++Good next bridge events:++++```text++EntityTakeDamage(entity, damageInfo)++PlayerUse(ply, entity)++PlayerCanPickupWeapon(ply, weapon)++PlayerLoadout(ply)++RoundStart++RoundEnd++```@@@ -3,9 +3,9 @@* Similar to Garry's Mod Lua hooks but for JavaScript** Usage:-- * hook.on('PlayerSpawn', 'myhandler', (player) => { console.log(player.name + ' spawned'); });-- * hook.emit('PlayerSpawn', player);-- * hook.off('PlayerSpawn', 'myhandler');++ * hook.Add('PlayerSpawn', 'myhandler', (player) => { console.log(player.name + ' spawned'); });++ * hook.Run('PlayerSpawn', player);++ * hook.Remove('PlayerSpawn', 'myhandler');*/class HookSystem {@@@ -29,6 +29,10 @@ class HookSystem {console.log(`[Hook] Registered: ${hookName} (${id})`);}++ Add(hookName, id, callback) {++ return this.on(hookName, id, callback);++ }++/*** Unregister a hook callback* @param {string} hookName - Event name@@@ -41,11 +45,15 @@ class HookSystem {}}++ Remove(hookName, id) {++ return this.off(hookName, id);++ }++/*** Emit an event, calling all registered hooks* @param {string} hookName - Event name* @param {...any} args - Arguments to pass to handlers-- * @returns {boolean|any} - Return value from handlers (stops on first truthy)++ * @returns {boolean|any} - First defined return value from handlers*/emit(hookName, ...args) {if (!this.hooks.has(hookName)) {@@@ -53,20 +61,33 @@ class HookSystem {}const handlers = this.hooks.get(hookName);-- let result = null;--for (const [id, callback] of handlers) {try {const ret = callback(...args);-- if (ret !== undefined && ret !== null) {-- result = ret; // Allow chaining return values++ if (ret !== undefined) {++ return ret;}} catch (err) {console.error(`[Hook] Error in ${hookName} (${id}):`, err.message);}}-- return result;++ return undefined;++ }++++ Run(hookName, ...args) {++ return this.emit(hookName, ...args);++ }++++ Call(hookName, gamemode, ...args) {++ const result = this.emit(hookName, ...args);++ if (result !== undefined) return result;++++ if (gamemode && typeof gamemode[hookName] === 'function') {++ return gamemode[hookName](...args);++ }++++ return undefined;}/**@@@ -108,6 +129,16 @@ class HookSystem {return this.hooks.get(hookName) || new Map();}++ GetTable(hookName) {++ if (hookName) return Array.from(this.getHooks(hookName).keys());++++ const out = {};++ for (const [name, handlers] of this.hooks.entries()) {++ out[name] = Array.from(handlers.keys());++ }++ return out;++ }++/*** Clear all hooks (for testing)*/nnew file mode 100644@@@ -0,0 +1,138 @@++(function () {++ const commands = Object.create(null);++++ function normalize(name) {++ return String(name || "").trim().replace(/^!+/, "").toLowerCase();++ }++++ function split(text) {++ const raw = String(text || "").trim();++ if (!raw) return { name: "", args: [], raw: "" };++ const parts = raw.split(/\s+/g);++ const name = normalize(parts.shift());++ return { name, args: parts, raw };++ }++++ function reply(ply, msg) {++ if (ply && typeof ply.chat === "function") ply.chat(String(msg));++ else if (globalThis.OV && typeof OV.broadcast === "function") OV.broadcast(String(msg));++ else if (globalThis.OV && typeof OV.log === "function") OV.log(String(msg));++ }++++ function listNames() {++ return Object.keys(commands).sort();++ }++++ globalThis.command = {++ add(name, helpOrFn, maybeFn) {++ const key = normalize(name);++ if (!key) throw new Error("command.add requires a command name");++++ let help = "";++ let fn = helpOrFn;++ if (typeof helpOrFn === "string") {++ help = helpOrFn;++ fn = maybeFn;++ }++++ if (typeof fn !== "function") throw new Error("command.add requires a function");++ commands[key] = { help, fn };++ },++++ remove(name) {++ delete commands[normalize(name)];++ },++++ has(name) {++ return !!commands[normalize(name)];++ },++++ list: listNames,++++ run(ply, text) {++ const parsed = split(text);++ if (!parsed.name) return undefined;++++ const entry = commands[parsed.name];++ if (!entry) {++ reply(ply, `Unknown command: ${parsed.name}. Try !help`);++ return false;++ }++++ const result = entry.fn({ ply, name: parsed.name, args: parsed.args, raw: parsed.raw, reply });++ return result === undefined ? false : result;++ },++++ dispatchConsole(text) {++ return this.run(null, text);++ },++++ Add(name, helpOrFn, maybeFn) {++ return this.add(name, helpOrFn, maybeFn);++ },++++ Remove(name) {++ return this.remove(name);++ },++++ Run(ply, text) {++ return this.run(ply, text);++ },++++ List() {++ return this.list();++ }++ };++++ globalThis.concommand = {++ Add(name, fn, help) {++ return command.add(name, help || "", function ({ args, raw, reply }) {++ return fn(args, raw, reply);++ });++ },++++ Remove(name) {++ return command.remove(name);++ },++++ Run(text) {++ return command.dispatchConsole(text);++ },++++ List() {++ return command.list();++ }++ };++++ hook.add("PlayerSay", "OpenVibeCommandRegistry", function (ply, text) {++ const msg = String(text || "").trim();++ if (!msg.startsWith("!")) return undefined;++ return command.run(ply, msg.slice(1));++ });++++ hook.add("ConsoleCommand", "OpenVibeConsoleCommandRegistry", function (text) {++ return command.dispatchConsole(text);++ });++++ command.add("help", "List OpenVibe JS commands", function ({ reply, ply }) {++ reply(ply, `Commands: ${listNames().map((name) => `!${name}`).join(", ")}`);++ return false;++ });++++ command.add("smoke", "Run the embedded JS smoke test", function ({ reply, ply }) {++ reply(ply, "OpenVibe embedded JS smoke test passed.");++ return false;++ });++++ command.add("timer_smoke", "Run the JS timer smoke test", function ({ reply, ply }) {++ reply(ply, "Timer smoke scheduled.");++ if (globalThis.timer && typeof timer.simple === "function") {++ timer.simple(0.05, function () {++ if (globalThis.OV && typeof OV.broadcast === "function") {++ OV.broadcast("OpenVibe JS timer smoke fired.");++ }++ });++ }++ return false;++ });++})();nnew file mode 100644@@@ -0,0 +1,70 @@++(function () {++ let base = null;++ let current = null;++++ function publish(gm) {++ current = gm;++ globalThis.GM = gm;++ globalThis.GAMEMODE = gm;++ return gm;++ }++++ function derive(gm, parent) {++ if (!gm || typeof gm !== "object") throw new Error("gamemode.derive(gm) requires an object");++++ const prototype = parent || base || null;++ const out = prototype ? Object.create(prototype) : {};++ for (const key of Object.keys(gm)) out[key] = gm[key];++ return out;++ }++++ function set(gm, options) {++ if (!gm || typeof gm !== "object") throw new Error("gamemode.set(gm) requires an object");++++ const opts = options || {};++ if (!base || opts.base === true || gm.mode === "base") {++ base = gm;++ return publish(gm);++ }++++ return publish(derive(gm, base));++ }++++ function setBase(gm) {++ if (!gm || typeof gm !== "object") throw new Error("gamemode.setBase(gm) requires an object");++ base = gm;++ if (!current) publish(gm);++ return gm;++ }++++ function get() {++ return current;++ }++++ function getBase() {++ return base;++ }++++ function call(name, ...args) {++ return hook.call(name, current, ...args);++ }++++ globalThis.gamemode = {++ set,++ setBase,++ derive,++ get,++ getBase,++ call,++++ Set: set,++ SetBase: setBase,++ Derive: derive,++ Get: get,++ GetBase: getBase,++ Call: call++ };++++ globalThis.GM = null;++ globalThis.GAMEMODE = null;++})();nnew file mode 100644@@@ -0,0 +1,89 @@++(function () {++ const buckets = Object.create(null);++++ function ensure(name) {++ name = String(name || "");++ if (!buckets[name]) buckets[name] = [];++ return buckets[name];++ }++++ function logError(name, id, err) {++ const message = err && err.stack ? err.stack : String(err);++ if (globalThis.OV && typeof OV.error === "function") {++ OV.error(`hook ${name}/${id}: ${message}`);++ } else {++ console.error(`hook ${name}/${id}: ${message}`);++ }++ }++++ function add(name, id, fn) {++ if (!name || !id || typeof fn !== "function") {++ throw new Error("hook.add(name, id, fn) requires a hook name, id, and function");++ }++++ const list = ensure(name);++ const sid = String(id);++ for (let i = list.length - 1; i >= 0; --i) {++ if (list[i].id === sid) list.splice(i, 1);++ }++ list.push({ id: sid, fn });++ }++++ function remove(name, id) {++ const list = buckets[String(name || "")];++ if (!list) return;++ const sid = String(id || "");++ for (let i = list.length - 1; i >= 0; --i) {++ if (list[i].id === sid) list.splice(i, 1);++ }++ }++++ function call(name, gm, ...args) {++ const list = buckets[String(name || "")];++ if (list) {++ const snapshot = list.slice();++ for (const item of snapshot) {++ try {++ const result = item.fn(...args);++ if (result !== undefined) return result;++ } catch (err) {++ logError(name, item.id, err);++ }++ }++ }++++ if (gm && typeof gm[name] === "function") {++ return gm[name](...args);++ }++++ return undefined;++ }++++ function run(name, ...args) {++ const gm = globalThis.gamemode && typeof gamemode.get === "function"++ ? gamemode.get()++ : globalThis.GAMEMODE;++ return call(name, gm, ...args);++ }++++ function list(name) {++ if (name) return (buckets[String(name)] || []).map((item) => item.id);++ const out = Object.create(null);++ for (const key of Object.keys(buckets)) out[key] = buckets[key].map((item) => item.id);++ return out;++ }++++ globalThis.hook = {++ add,++ remove,++ run,++ call,++ list,++++ Add: add,++ Remove: remove,++ Run: run,++ Call: call,++ GetTable: list++ };++})();nnew file mode 100644@@@ -0,0 +1,73 @@++(function () {++ const timers = Object.create(null);++ let nextAutoId = 1;++++ function now() {++ if (globalThis.OV && typeof OV.time === "function") return Number(OV.time()) || 0;++ return Date.now() / 1000;++ }++++ function create(id, delay, reps, fn) {++ if (!id) id = `timer_${nextAutoId++}`;++ if (typeof fn !== "function") throw new Error("timer.create requires a callback");++++ delay = Math.max(0, Number(delay) || 0);++ reps = Number(reps);++ if (!Number.isFinite(reps)) reps = 1;++++ timers[String(id)] = {++ id: String(id),++ delay,++ reps,++ count: 0,++ next: now() + delay,++ fn++ };++++ return String(id);++ }++++ function remove(id) {++ delete timers[String(id || "")];++ }++++ function tick() {++ const t = now();++ for (const id of Object.keys(timers)) {++ const item = timers[id];++ if (!item || t < item.next) continue;++++ item.count += 1;++ try {++ item.fn(item.count, id);++ } catch (err) {++ if (globalThis.OV && typeof OV.error === "function") OV.error(`timer ${id}: ${err && err.message ? err.message : err}`);++ delete timers[id];++ continue;++ }++++ if (item.reps > 0 && item.count >= item.reps) {++ delete timers[id];++ } else {++ item.next = t + item.delay;++ }++ }++ }++++ globalThis.timer = {++ create,++ remove,++ exists(id) {++ return !!timers[String(id || "")];++ },++ simple(delay, fn) {++ return create(`simple_${nextAutoId++}`, delay, 1, fn);++ },++ tick,++ list() {++ return Object.keys(timers).sort();++ }++ };++++ hook.add("Think", "OpenVibeTimerThink", tick);++})();@@@ -1,5 +1,5 @@(function () {-- const BaseServerGM = {++ const GM = {mode: "base",name: "OpenVibe Base",@@@ -16,10 +16,20 @@},PlayerSpawn(_ply) {},++PlayerDeath(_victim, _attacker) {},-- PlayerDisconnected(_ply) {},++++ PlayerDisconnected(ply) {++ if (ply && typeof OV.log === "function") OV.log(`${ply.name()} disconnected.`);++ },++++ PlayerSay(_ply, _text) {++ return undefined;++ },++Think() {}};-- gamemode.set(BaseServerGM);++ gamemode.setBase(GM);++ gamemode.set(GM, { base: true });})();@@@ -7,12 +7,17 @@});}-- const DeathrunGM = {++ const GM = {mode: "deathrun",name: "OpenVibe Deathrun",Initialize() { OV.log("Deathrun Initialize fired"); registerCommands(); },PlayerInitialSpawn(ply) { ply.chat("Deathrun JS loaded. Try !dr_status"); },Think() {}};-- gamemode.set(DeathrunGM);++++ hook.Add("PlayerSpawn", "OpenVibeDeathrunSpawnTip", function (ply) {++ ply.chat("Reach the end. Avoid traps. Trust nothing.");++ });++++ gamemode.set(GM);})();@@@ -14,12 +14,17 @@});}-- const FortWarsGM = {++ const GM = {mode: "fortwars",name: "OpenVibe Fort Wars",Initialize() { OV.log("Fort Wars Initialize fired"); registerCommands(); },PlayerInitialSpawn(ply) { ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate"); },Think() {}};-- gamemode.set(FortWarsGM);++++ hook.Add("PlayerSpawn", "OpenVibeFortWarsSpawnTip", function (ply) {++ ply.chat("Build phase: use !build crate, !build pallet, or !build fence.");++ });++++ gamemode.set(GM);})();@@@ -29,7 +29,7 @@});}-- const HubServerGM = {++ const GM = {mode: "hub",name: "OpenVibe Hub",@@@ -63,5 +63,5 @@Think() {}};-- gamemode.set(HubServerGM);++ gamemode.set(GM);})();@@@ -14,12 +14,16 @@});}-- const PropHuntGM = {++ const GM = {mode: "prophunt",name: "OpenVibe Prop Hunt",Initialize() { OV.log("Prop Hunt Initialize fired"); registerCommands(); },PlayerInitialSpawn(ply) { ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate"); },Think() {}};-- gamemode.set(PropHuntGM);++ hook.Add("PlayerSpawn", "OpenVibePropHuntSpawnTip", function (ply) {++ ply.chat("Blend in. Use !disguise crate, !disguise barrel, or !disguise chair.");++ });++++ gamemode.set(GM);})();@@@ -7,12 +7,17 @@});}-- const TraitorTownGM = {++ const GM = {mode: "traitortown",name: "OpenVibe Traitor Town",Initialize() { OV.log("Traitor Town Initialize fired"); registerCommands(); },PlayerInitialSpawn(ply) { ply.chat("Traitor Town JS loaded. Try !ttt_status"); },Think() {}};-- gamemode.set(TraitorTownGM);++++ hook.Add("PlayerSpawn", "OpenVibeTraitorTownSpawnTip", function (ply) {++ ply.chat("Watch the room. Someone here is lying.");++ });++++ gamemode.set(GM);})();@@@ -58,6 +58,18 @@ context.gamemode.call("Initialize");context.gamemode.call("MapInitialize", "ov_hub");context.gamemode.call("PlayerInitialSpawn", player());++context.hook.Add("OpenVibeSmoke", "first", () => undefined);++context.hook.Add("OpenVibeSmoke", "second", () => "hook-result");++context.GM.OpenVibeSmoke = () => "gm-result";++if (context.hook.Run("OpenVibeSmoke") !== "hook-result") {++ throw new Error("hook.Run did not return first defined hook result");++}++++context.hook.Remove("OpenVibeSmoke", "second");++if (context.hook.Run("OpenVibeSmoke") !== "gm-result") {++ throw new Error("hook.Run did not fall back to GM method");++}++const blocked = context.gamemode.call("PlayerSay", player(), "!js");if (blocked !== false) throw new Error("!js did not block default chat");@@@ -70,6 +82,7 @@ context.gamemode.call("Think");if (!messages.some((line) => line.includes("JavaScript hooks are working"))) throw new Error("missing !js response");if (!messages.some((line) => line.includes("OpenVibe embedded JS smoke test passed"))) throw new Error("missing smoke response");if (!messages.some((line) => line.includes("OpenVibe JS timer smoke fired"))) throw new Error("missing timer smoke response");++if (context.GAMEMODE.mode !== "hub") throw new Error("GAMEMODE global was not published");console.log(messages.join("\n"));console.log("[openvibe-smoke] JS command/timer smoke passed");nnew file mode 100644@@@ -0,0 +1,88 @@++import fs from "node:fs";++import vm from "node:vm";++++const modeCommands = {++ hub: "hub_status",++ prophunt: "ph_status",++ deathrun: "dr_status",++ fortwars: "fw_status",++ traitortown: "ttt_status",++};++++function makePlayer(messages) {++ return {++ userId: () => 1,++ entIndex: () => 1,++ steamId: () => "STEAM_0:1:1",++ name: () => "SmokePlayer",++ health: () => 100,++ setHealth: () => undefined,++ chat: (msg) => messages.push(`[chat] ${msg}`),++ team: () => 0,++ setTeam: () => undefined,++ runCommand: (cmd) => messages.push(`[runCommand] ${cmd}`),++ };++}++++function runFile(context, file) {++ const code = fs.readFileSync(file, "utf8");++ vm.runInContext(code, context, { filename: file });++}++++for (const [mode, commandName] of Object.entries(modeCommands)) {++ let fakeTime = 0;++ const messages = [];++++ const context = vm.createContext({++ console,++ globalThis: {},++ OV: {++ log: (msg) => messages.push(`[log] ${msg}`),++ warn: (msg) => messages.push(`[warn] ${msg}`),++ error: (msg) => messages.push(`[error] ${msg}`),++ getMode: () => mode,++ getMapName: () => `${mode}_map`,++ time: () => fakeTime,++ broadcast: (msg) => messages.push(`[broadcast] ${msg}`),++ players: () => [makePlayer(messages)],++ playerByUserId: () => makePlayer(messages),++ serverCommand: (cmd) => messages.push(`[serverCommand] ${cmd}`),++ reward: () => undefined,++ endMatch: () => undefined,++ },++ });++ context.globalThis = context;++++ [++ "game/openvibe.games/js/core/hook.js",++ "game/openvibe.games/js/core/gamemode.js",++ "game/openvibe.games/js/bridge.js",++ "game/openvibe.games/js/core/command.js",++ "game/openvibe.games/js/core/timer.js",++ "game/openvibe.games/js/gamemodes/base/server.js",++ `game/openvibe.games/js/gamemodes/${mode}/server.js`,++ ].forEach((file) => runFile(context, file));++++ const ply = makePlayer(messages);++ context.gamemode.call("Initialize");++ context.gamemode.call("MapInitialize", `${mode}_map`);++ context.gamemode.call("PlayerInitialSpawn", ply);++ context.gamemode.call("PlayerSpawn", ply);++ context.gamemode.call("ConsoleCommand", commandName);++ fakeTime += 1;++ context.gamemode.call("Think");++++ if (!context.GAMEMODE || context.GAMEMODE.mode !== mode) {++ throw new Error(`${mode}: GAMEMODE global was not set`);++ }++++ if (!context.command.has(commandName)) {++ throw new Error(`${mode}: missing command ${commandName}`);++ }++++ if (!messages.some((line) => line.includes("JS loaded") || line.includes("Welcome"))) {++ throw new Error(`${mode}: missing join/welcome output`);++ }++}++++console.log("[openvibe-smoke] all JS gamemodes loaded and handled lifecycle hooks");
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+# OpenVibe JavaScript Scripting
+
+OpenVibe embeds QuickJS in the Source server DLL and exposes a small
+GMod-style hook/gamemode API.
+
+## File Layout
+
+```text
+game/openvibe.games/js/
+ core/
+ hook.js
+ gamemode.js
+ command.js
+ timer.js
+ bridge.js
+ gamemodes/
+ base/server.js
+ hub/server.js
+ prophunt/server.js
+ deathrun/server.js
+ fortwars/server.js
+ traitortown/server.js
+```
+
+The server loads core files first, then `base/server.js`, then the active mode:
+
+```text
+js/gamemodes/<ov_mode>/server.js
+```
+
+## Hook API
+
+Use the GMod-shaped API:
+
+```js
+hook.Add("PlayerSpawn", "MyAddon.SpawnMessage", function (ply) {
+ ply.chat("Spawn hook from JS.");
+});
+
+hook.Remove("PlayerSpawn", "MyAddon.SpawnMessage");
+
+const result = hook.Run("SomeHook", arg1, arg2);
+```
+
+Lowercase aliases also exist:
+
+```js
+hook.add("PlayerSpawn", "id", fn);
+hook.remove("PlayerSpawn", "id");
+hook.run("PlayerSpawn", ply);
+```
+
+Execution order:
+
+1. Registered hooks run in insertion order.
+2. The first hook that returns anything other than `undefined` wins.
+3. If no hook returns a value, the active `GM` method runs.
+
+This mirrors the GMod pattern where addon hooks can override gamemode behavior.
+
+## Gamemode API
+
+`base/server.js` defines the base `GM`. Active modes inherit from it:
+
+```js
+(function () {
+ const GM = {
+ mode: "deathrun",
+ name: "OpenVibe Deathrun",
+
+ Initialize() {
+ OV.log("Deathrun initialized.");
+ },
+
+ PlayerInitialSpawn(ply) {
+ ply.chat("Welcome to Deathrun.");
+ }
+ };
+
+ gamemode.set(GM);
+})();
+```
+
+Globals:
+
+```js
+GM
+GAMEMODE
+gamemode.get()
+gamemode.getBase()
+gamemode.call("HookName", ...args)
+```
+
+## Events Exposed Today
+
+The native server bridge currently fires:
+
+```text
+Initialize
+MapInitialize(mapName)
+Think
+PlayerInitialSpawn(ply)
+PlayerSpawn(ply)
+PlayerDeath(victim, attacker)
+PlayerDisconnected(ply)
+PlayerSay(ply, text)
+ConsoleCommand(text)
+Shutdown
+```
+
+## Commands
+
+Chat commands are routed through the `PlayerSay` hook.
+
+```js
+command.Add("where", "Show mode and map", function ({ ply, reply }) {
+ reply(ply, `Mode=${OV.getMode()} map=${OV.getMapName()}`);
+ return false;
+});
+```
+
+Players type:
+
+```text
+!where
+```
+
+Returning `false` blocks normal chat, matching the current C++ bridge behavior.
+
+Console commands can be sent from SRCDS:
+
+```text
+ov_js_cmd where
+```
+
+## Timers
+
+Timers are advanced from the `Think` hook:
+
+```js
+timer.simple(3, function () {
+ OV.broadcast("Three seconds passed.");
+});
+
+timer.create("round_tick", 1, 0, function (count) {
+ OV.log(`Round tick ${count}`);
+});
+```
+
+`reps=0` means repeat forever.
+
+## Native Bridge
+
+Available server APIs:
+
+```js
+OV.log(msg)
+OV.warn(msg)
+OV.error(msg)
+OV.getMode()
+OV.getMapName()
+OV.time()
+OV.players()
+OV.playerByUserId(userId)
+OV.broadcast(msg)
+OV.serverCommand(cmd)
+OV.fireHook(name, ...args)
+```
+
+Player wrapper APIs:
+
+```js
+ply.userId()
+ply.entIndex()
+ply.steamId()
+ply.name()
+ply.health()
+ply.setHealth(value)
+ply.team()
+ply.setTeam(team)
+ply.chat(msg)
+ply.runCommand("ov_prophunt_disguise crate")
+```
+
+Server and player command execution is allowlisted to `ov_*` commands.
+
+## Reloading
+
+In SRCDS:
+
+```text
+ov_js_status
+ov_js_reload
+ov_js_fire Initialize
+ov_js_cmd smoke
+```
+
+## Next Native Hooks To Add
+
+Good next bridge events:
+
+```text
+EntityTakeDamage(entity, damageInfo)
+PlayerUse(ply, entity)
+PlayerCanPickupWeapon(ply, weapon)
+PlayerLoadout(ply)
+RoundStart
+RoundEnd
+```
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
* Similar to Garry's Mod Lua hooks but for JavaScript
* Similar to Garry's Mod Lua hooks but for JavaScript
*
*
* Usage:
* Usage:
- * hook.on('PlayerSpawn', 'myhandler', (player) => { console.log(player.name + ' spawned'); });
- * hook.emit('PlayerSpawn', player);
- * hook.off('PlayerSpawn', 'myhandler');
+ * hook.Add('PlayerSpawn', 'myhandler', (player) => { console.log(player.name + ' spawned'); });
+ * hook.Run('PlayerSpawn', player);
+ * hook.Remove('PlayerSpawn', 'myhandler');
*/
*/
class HookSystem {
class HookSystem {
@@ -29,6 +29,10 @@ class HookSystem {
@@ -29,6 +29,10 @@ class HookSystem {
console.log(`[Hook] Registered: ${hookName} (${id})`);
console.log(`[Hook] Registered: ${hookName} (${id})`);
}
}
+ Add(hookName, id, callback) {
+ return this.on(hookName, id, callback);
+ }
+
/**
/**
* Unregister a hook callback
* Unregister a hook callback
* @param {string} hookName - Event name
* @param {string} hookName - Event name
@@ -41,11 +45,15 @@ class HookSystem {
@@ -41,11 +45,15 @@ class HookSystem {
}
}
}
}
+ Remove(hookName, id) {
+ return this.off(hookName, id);
+ }
+
/**
/**
* Emit an event, calling all registered hooks
* Emit an event, calling all registered hooks
* @param {string} hookName - Event name
* @param {string} hookName - Event name
* @param {...any} args - Arguments to pass to handlers
* @param {...any} args - Arguments to pass to handlers
- * @returns {boolean|any} - Return value from handlers (stops on first truthy)
+ * @returns {boolean|any} - First defined return value from handlers
*/
*/
emit(hookName, ...args) {
emit(hookName, ...args) {
if (!this.hooks.has(hookName)) {
if (!this.hooks.has(hookName)) {
@@ -53,20 +61,33 @@ class HookSystem {
@@ -53,20 +61,33 @@ class HookSystem {
}
}
const handlers = this.hooks.get(hookName);
const handlers = this.hooks.get(hookName);
- let result = null;
-
for (const [id, callback] of handlers) {
for (const [id, callback] of handlers) {
try {
try {
const ret = callback(...args);
const ret = callback(...args);
- if (ret !== undefined && ret !== null) {
- result = ret; // Allow chaining return values
+ if (ret !== undefined) {
+ return ret;
}
}
} catch (err) {
} catch (err) {
console.error(`[Hook] Error in ${hookName} (${id}):`, err.message);
console.error(`[Hook] Error in ${hookName} (${id}):`, err.message);
}
}
}
}
- return result;
+ return undefined;
+ }
+
+ Run(hookName, ...args) {
+ return this.emit(hookName, ...args);
+ }
+
+ Call(hookName, gamemode, ...args) {
+ const result = this.emit(hookName, ...args);
+ if (result !== undefined) return result;
+
+ if (gamemode && typeof gamemode[hookName] === 'function') {
+ return gamemode[hookName](...args);
+ }
+
+ return undefined;
}
}
/**
/**
@@ -108,6 +129,16 @@ class HookSystem {
@@ -108,6 +129,16 @@ class HookSystem {
return this.hooks.get(hookName) || new Map();
return this.hooks.get(hookName) || new Map();
}
}
+ GetTable(hookName) {
+ if (hookName) return Array.from(this.getHooks(hookName).keys());
+
+ const out = {};
+ for (const [name, handlers] of this.hooks.entries()) {
+ out[name] = Array.from(handlers.keys());
+ }
+ return out;
+ }
+
/**
/**
* Clear all hooks (for testing)
* Clear all hooks (for testing)
*/
*/
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -0,0 +1,138 @@
+(function () {
+ const commands = Object.create(null);
+
+ function normalize(name) {
+ return String(name || "").trim().replace(/^!+/, "").toLowerCase();
+ }
+
+ function split(text) {
+ const raw = String(text || "").trim();
+ if (!raw) return { name: "", args: [], raw: "" };
+ const parts = raw.split(/\s+/g);
+ const name = normalize(parts.shift());
+ return { name, args: parts, raw };
+ }
+
+ function reply(ply, msg) {
+ if (ply && typeof ply.chat === "function") ply.chat(String(msg));
+ else if (globalThis.OV && typeof OV.broadcast === "function") OV.broadcast(String(msg));
+ else if (globalThis.OV && typeof OV.log === "function") OV.log(String(msg));
+ }
+
+ function listNames() {
+ return Object.keys(commands).sort();
+ }
+
+ globalThis.command = {
+ add(name, helpOrFn, maybeFn) {
+ const key = normalize(name);
+ if (!key) throw new Error("command.add requires a command name");
+
+ let help = "";
+ let fn = helpOrFn;
+ if (typeof helpOrFn === "string") {
+ help = helpOrFn;
+ fn = maybeFn;
+ }
+
+ if (typeof fn !== "function") throw new Error("command.add requires a function");
+ commands[key] = { help, fn };
+ },
+
+ remove(name) {
+ delete commands[normalize(name)];
+ },
+
+ has(name) {
+ return !!commands[normalize(name)];
+ },
+
+ list: listNames,
+
+ run(ply, text) {
+ const parsed = split(text);
+ if (!parsed.name) return undefined;
+
+ const entry = commands[parsed.name];
+ if (!entry) {
+ reply(ply, `Unknown command: ${parsed.name}. Try !help`);
+ return false;
+ }
+
+ const result = entry.fn({ ply, name: parsed.name, args: parsed.args, raw: parsed.raw, reply });
+ return result === undefined ? false : result;
+ },
+
+ dispatchConsole(text) {
+ return this.run(null, text);
+ },
+
+ Add(name, helpOrFn, maybeFn) {
+ return this.add(name, helpOrFn, maybeFn);
+ },
+
+ Remove(name) {
+ return this.remove(name);
+ },
+
+ Run(ply, text) {
+ return this.run(ply, text);
+ },
+
+ List() {
+ return this.list();
+ }
+ };
+
+ globalThis.concommand = {
+ Add(name, fn, help) {
+ return command.add(name, help || "", function ({ args, raw, reply }) {
+ return fn(args, raw, reply);
+ });
+ },
+
+ Remove(name) {
+ return command.remove(name);
+ },
+
+ Run(text) {
+ return command.dispatchConsole(text);
+ },
+
+ List() {
+ return command.list();
+ }
+ };
+
+ hook.add("PlayerSay", "OpenVibeCommandRegistry", function (ply, text) {
+ const msg = String(text || "").trim();
+ if (!msg.startsWith("!")) return undefined;
+ return command.run(ply, msg.slice(1));
+ });
+
+ hook.add("ConsoleCommand", "OpenVibeConsoleCommandRegistry", function (text) {
+ return command.dispatchConsole(text);
+ });
+
+ command.add("help", "List OpenVibe JS commands", function ({ reply, ply }) {
+ reply(ply, `Commands: ${listNames().map((name) => `!${name}`).join(", ")}`);
+ return false;
+ });
+
+ command.add("smoke", "Run the embedded JS smoke test", function ({ reply, ply }) {
+ reply(ply, "OpenVibe embedded JS smoke test passed.");
+ return false;
+ });
+
+ command.add("timer_smoke", "Run the JS timer smoke test", function ({ reply, ply }) {
+ reply(ply, "Timer smoke scheduled.");
+ if (globalThis.timer && typeof timer.simple === "function") {
+ timer.simple(0.05, function () {
+ if (globalThis.OV && typeof OV.broadcast === "function") {
+ OV.broadcast("OpenVibe JS timer smoke fired.");
+ }
+ });
+ }
+ return false;
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+(function () {
+ let base = null;
+ let current = null;
+
+ function publish(gm) {
+ current = gm;
+ globalThis.GM = gm;
+ globalThis.GAMEMODE = gm;
+ return gm;
+ }
+
+ function derive(gm, parent) {
+ if (!gm || typeof gm !== "object") throw new Error("gamemode.derive(gm) requires an object");
+
+ const prototype = parent || base || null;
+ const out = prototype ? Object.create(prototype) : {};
+ for (const key of Object.keys(gm)) out[key] = gm[key];
+ return out;
+ }
+
+ function set(gm, options) {
+ if (!gm || typeof gm !== "object") throw new Error("gamemode.set(gm) requires an object");
+
+ const opts = options || {};
+ if (!base || opts.base === true || gm.mode === "base") {
+ base = gm;
+ return publish(gm);
+ }
+
+ return publish(derive(gm, base));
+ }
+
+ function setBase(gm) {
+ if (!gm || typeof gm !== "object") throw new Error("gamemode.setBase(gm) requires an object");
+ base = gm;
+ if (!current) publish(gm);
+ return gm;
+ }
+
+ function get() {
+ return current;
+ }
+
+ function getBase() {
+ return base;
+ }
+
+ function call(name, ...args) {
+ return hook.call(name, current, ...args);
+ }
+
+ globalThis.gamemode = {
+ set,
+ setBase,
+ derive,
+ get,
+ getBase,
+ call,
+
+ Set: set,
+ SetBase: setBase,
+ Derive: derive,
+ Get: get,
+ GetBase: getBase,
+ Call: call
+ };
+
+ globalThis.GM = null;
+ globalThis.GAMEMODE = null;
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
+(function () {
+ const buckets = Object.create(null);
+
+ function ensure(name) {
+ name = String(name || "");
+ if (!buckets[name]) buckets[name] = [];
+ return buckets[name];
+ }
+
+ function logError(name, id, err) {
+ const message = err && err.stack ? err.stack : String(err);
+ if (globalThis.OV && typeof OV.error === "function") {
+ OV.error(`hook ${name}/${id}: ${message}`);
+ } else {
+ console.error(`hook ${name}/${id}: ${message}`);
+ }
+ }
+
+ function add(name, id, fn) {
+ if (!name || !id || typeof fn !== "function") {
+ throw new Error("hook.add(name, id, fn) requires a hook name, id, and function");
+ }
+
+ const list = ensure(name);
+ const sid = String(id);
+ for (let i = list.length - 1; i >= 0; --i) {
+ if (list[i].id === sid) list.splice(i, 1);
+ }
+ list.push({ id: sid, fn });
+ }
+
+ function remove(name, id) {
+ const list = buckets[String(name || "")];
+ if (!list) return;
+ const sid = String(id || "");
+ for (let i = list.length - 1; i >= 0; --i) {
+ if (list[i].id === sid) list.splice(i, 1);
+ }
+ }
+
+ function call(name, gm, ...args) {
+ const list = buckets[String(name || "")];
+ if (list) {
+ const snapshot = list.slice();
+ for (const item of snapshot) {
+ try {
+ const result = item.fn(...args);
+ if (result !== undefined) return result;
+ } catch (err) {
+ logError(name, item.id, err);
+ }
+ }
+ }
+
+ if (gm && typeof gm[name] === "function") {
+ return gm[name](...args);
+ }
+
+ return undefined;
+ }
+
+ function run(name, ...args) {
+ const gm = globalThis.gamemode && typeof gamemode.get === "function"
+ ? gamemode.get()
+ : globalThis.GAMEMODE;
+ return call(name, gm, ...args);
+ }
+
+ function list(name) {
+ if (name) return (buckets[String(name)] || []).map((item) => item.id);
+ const out = Object.create(null);
+ for (const key of Object.keys(buckets)) out[key] = buckets[key].map((item) => item.id);
+ return out;
+ }
+
+ globalThis.hook = {
+ add,
+ remove,
+ run,
+ call,
+ list,
+
+ Add: add,
+ Remove: remove,
+ Run: run,
+ Call: call,
+ GetTable: list
+ };
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+(function () {
+ const timers = Object.create(null);
+ let nextAutoId = 1;
+
+ function now() {
+ if (globalThis.OV && typeof OV.time === "function") return Number(OV.time()) || 0;
+ return Date.now() / 1000;
+ }
+
+ function create(id, delay, reps, fn) {
+ if (!id) id = `timer_${nextAutoId++}`;
+ if (typeof fn !== "function") throw new Error("timer.create requires a callback");
+
+ delay = Math.max(0, Number(delay) || 0);
+ reps = Number(reps);
+ if (!Number.isFinite(reps)) reps = 1;
+
+ timers[String(id)] = {
+ id: String(id),
+ delay,
+ reps,
+ count: 0,
+ next: now() + delay,
+ fn
+ };
+
+ return String(id);
+ }
+
+ function remove(id) {
+ delete timers[String(id || "")];
+ }
+
+ function tick() {
+ const t = now();
+ for (const id of Object.keys(timers)) {
+ const item = timers[id];
+ if (!item || t < item.next) continue;
+
+ item.count += 1;
+ try {
+ item.fn(item.count, id);
+ } catch (err) {
+ if (globalThis.OV && typeof OV.error === "function") OV.error(`timer ${id}: ${err && err.message ? err.message : err}`);
+ delete timers[id];
+ continue;
+ }
+
+ if (item.reps > 0 && item.count >= item.reps) {
+ delete timers[id];
+ } else {
+ item.next = t + item.delay;
+ }
+ }
+ }
+
+ globalThis.timer = {
+ create,
+ remove,
+ exists(id) {
+ return !!timers[String(id || "")];
+ },
+ simple(delay, fn) {
+ return create(`simple_${nextAutoId++}`, delay, 1, fn);
+ },
+ tick,
+ list() {
+ return Object.keys(timers).sort();
+ }
+ };
+
+ hook.add("Think", "OpenVibeTimerThink", tick);
+})();
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
(function () {
(function () {
- const BaseServerGM = {
+ const GM = {
mode: "base",
mode: "base",
name: "OpenVibe Base",
name: "OpenVibe Base",
@@ -16,10 +16,20 @@
@@ -16,10 +16,20 @@
},
},
PlayerSpawn(_ply) {},
PlayerSpawn(_ply) {},
+
PlayerDeath(_victim, _attacker) {},
PlayerDeath(_victim, _attacker) {},
- PlayerDisconnected(_ply) {},
+
+ PlayerDisconnected(ply) {
+ if (ply && typeof OV.log === "function") OV.log(`${ply.name()} disconnected.`);
+ },
+
+ PlayerSay(_ply, _text) {
+ return undefined;
+ },
+
Think() {}
Think() {}
};
};
- gamemode.set(BaseServerGM);
+ gamemode.setBase(GM);
+ gamemode.set(GM, { base: true });
})();
})();
@@ -7,12 +7,17 @@
@@ -7,12 +7,17 @@
});
});
}
}
- const DeathrunGM = {
+ const GM = {
mode: "deathrun",
mode: "deathrun",
name: "OpenVibe Deathrun",
name: "OpenVibe Deathrun",
Initialize() { OV.log("Deathrun Initialize fired"); registerCommands(); },
Initialize() { OV.log("Deathrun Initialize fired"); registerCommands(); },
PlayerInitialSpawn(ply) { ply.chat("Deathrun JS loaded. Try !dr_status"); },
PlayerInitialSpawn(ply) { ply.chat("Deathrun JS loaded. Try !dr_status"); },
Think() {}
Think() {}
};
};
- gamemode.set(DeathrunGM);
+
+ hook.Add("PlayerSpawn", "OpenVibeDeathrunSpawnTip", function (ply) {
+ ply.chat("Reach the end. Avoid traps. Trust nothing.");
+ });
+
+ gamemode.set(GM);
})();
})();
@@ -14,12 +14,17 @@
@@ -14,12 +14,17 @@
});
});
}
}
- const FortWarsGM = {
+ const GM = {
mode: "fortwars",
mode: "fortwars",
name: "OpenVibe Fort Wars",
name: "OpenVibe Fort Wars",
Initialize() { OV.log("Fort Wars Initialize fired"); registerCommands(); },
Initialize() { OV.log("Fort Wars Initialize fired"); registerCommands(); },
PlayerInitialSpawn(ply) { ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate"); },
PlayerInitialSpawn(ply) { ply.chat("Fort Wars JS loaded. Try !fw_status or !build crate"); },
Think() {}
Think() {}
};
};
- gamemode.set(FortWarsGM);
+
+ hook.Add("PlayerSpawn", "OpenVibeFortWarsSpawnTip", function (ply) {
+ ply.chat("Build phase: use !build crate, !build pallet, or !build fence.");
+ });
+
+ gamemode.set(GM);
})();
})();
@@ -29,7 +29,7 @@
@@ -29,7 +29,7 @@
});
});
}
}
- const HubServerGM = {
+ const GM = {
mode: "hub",
mode: "hub",
name: "OpenVibe Hub",
name: "OpenVibe Hub",
@@ -63,5 +63,5 @@
@@ -63,5 +63,5 @@
Think() {}
Think() {}
};
};
- gamemode.set(HubServerGM);
+ gamemode.set(GM);
})();
})();
@@ -14,12 +14,16 @@
@@ -14,12 +14,16 @@
});
});
}
}
- const PropHuntGM = {
+ const GM = {
mode: "prophunt",
mode: "prophunt",
name: "OpenVibe Prop Hunt",
name: "OpenVibe Prop Hunt",
Initialize() { OV.log("Prop Hunt Initialize fired"); registerCommands(); },
Initialize() { OV.log("Prop Hunt Initialize fired"); registerCommands(); },
PlayerInitialSpawn(ply) { ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate"); },
PlayerInitialSpawn(ply) { ply.chat("Prop Hunt JS loaded. Try !ph_status or !disguise crate"); },
Think() {}
Think() {}
};
};
- gamemode.set(PropHuntGM);
+ hook.Add("PlayerSpawn", "OpenVibePropHuntSpawnTip", function (ply) {
+ ply.chat("Blend in. Use !disguise crate, !disguise barrel, or !disguise chair.");
+ });
+
+ gamemode.set(GM);
})();
})();
@@ -7,12 +7,17 @@
@@ -7,12 +7,17 @@
});
});
}
}
- const TraitorTownGM = {
+ const GM = {
mode: "traitortown",
mode: "traitortown",
name: "OpenVibe Traitor Town",
name: "OpenVibe Traitor Town",
Initialize() { OV.log("Traitor Town Initialize fired"); registerCommands(); },
Initialize() { OV.log("Traitor Town Initialize fired"); registerCommands(); },
PlayerInitialSpawn(ply) { ply.chat("Traitor Town JS loaded. Try !ttt_status"); },
PlayerInitialSpawn(ply) { ply.chat("Traitor Town JS loaded. Try !ttt_status"); },
Think() {}
Think() {}
};
};
- gamemode.set(TraitorTownGM);
+
+ hook.Add("PlayerSpawn", "OpenVibeTraitorTownSpawnTip", function (ply) {
+ ply.chat("Watch the room. Someone here is lying.");
+ });
+
+ gamemode.set(GM);
})();
})();
@@ -58,6 +58,18 @@ context.gamemode.call("Initialize");
@@ -58,6 +58,18 @@ context.gamemode.call("Initialize");
context.gamemode.call("MapInitialize", "ov_hub");
context.gamemode.call("MapInitialize", "ov_hub");
context.gamemode.call("PlayerInitialSpawn", player());
context.gamemode.call("PlayerInitialSpawn", player());
+context.hook.Add("OpenVibeSmoke", "first", () => undefined);
+context.hook.Add("OpenVibeSmoke", "second", () => "hook-result");
+context.GM.OpenVibeSmoke = () => "gm-result";
+if (context.hook.Run("OpenVibeSmoke") !== "hook-result") {
+ throw new Error("hook.Run did not return first defined hook result");
+}
+
+context.hook.Remove("OpenVibeSmoke", "second");
+if (context.hook.Run("OpenVibeSmoke") !== "gm-result") {
+ throw new Error("hook.Run did not fall back to GM method");
+}
+
const blocked = context.gamemode.call("PlayerSay", player(), "!js");
const blocked = context.gamemode.call("PlayerSay", player(), "!js");
if (blocked !== false) throw new Error("!js did not block default chat");
if (blocked !== false) throw new Error("!js did not block default chat");
@@ -70,6 +82,7 @@ context.gamemode.call("Think");
@@ -70,6 +82,7 @@ context.gamemode.call("Think");
if (!messages.some((line) => line.includes("JavaScript hooks are working"))) throw new Error("missing !js response");
if (!messages.some((line) => line.includes("JavaScript hooks are working"))) throw new Error("missing !js response");
if (!messages.some((line) => line.includes("OpenVibe embedded JS smoke test passed"))) throw new Error("missing smoke response");
if (!messages.some((line) => line.includes("OpenVibe embedded JS smoke test passed"))) throw new Error("missing smoke response");
if (!messages.some((line) => line.includes("OpenVibe JS timer smoke fired"))) throw new Error("missing timer smoke response");
if (!messages.some((line) => line.includes("OpenVibe JS timer smoke fired"))) throw new Error("missing timer smoke response");
+if (context.GAMEMODE.mode !== "hub") throw new Error("GAMEMODE global was not published");
console.log(messages.join("\n"));
console.log(messages.join("\n"));
console.log("[openvibe-smoke] JS command/timer smoke passed");
console.log("[openvibe-smoke] JS command/timer smoke passed");
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+import fs from "node:fs";
+import vm from "node:vm";
+
+const modeCommands = {
+ hub: "hub_status",
+ prophunt: "ph_status",
+ deathrun: "dr_status",
+ fortwars: "fw_status",
+ traitortown: "ttt_status",
+};
+
+function makePlayer(messages) {
+ return {
+ userId: () => 1,
+ entIndex: () => 1,
+ steamId: () => "STEAM_0:1:1",
+ name: () => "SmokePlayer",
+ health: () => 100,
+ setHealth: () => undefined,
+ chat: (msg) => messages.push(`[chat] ${msg}`),
+ team: () => 0,
+ setTeam: () => undefined,
+ runCommand: (cmd) => messages.push(`[runCommand] ${cmd}`),
+ };
+}
+
+function runFile(context, file) {
+ const code = fs.readFileSync(file, "utf8");
+ vm.runInContext(code, context, { filename: file });
+}
+
+for (const [mode, commandName] of Object.entries(modeCommands)) {
+ let fakeTime = 0;
+ const messages = [];
+
+ const context = vm.createContext({
+ console,
+ globalThis: {},
+ OV: {
+ log: (msg) => messages.push(`[log] ${msg}`),
+ warn: (msg) => messages.push(`[warn] ${msg}`),
+ error: (msg) => messages.push(`[error] ${msg}`),
+ getMode: () => mode,
+ getMapName: () => `${mode}_map`,
+ time: () => fakeTime,
+ broadcast: (msg) => messages.push(`[broadcast] ${msg}`),
+ players: () => [makePlayer(messages)],
+ playerByUserId: () => makePlayer(messages),
+ serverCommand: (cmd) => messages.push(`[serverCommand] ${cmd}`),
+ reward: () => undefined,
+ endMatch: () => undefined,
+ },
+ });
+ context.globalThis = context;
+
+ [
+ "game/openvibe.games/js/core/hook.js",
+ "game/openvibe.games/js/core/gamemode.js",
+ "game/openvibe.games/js/bridge.js",
+ "game/openvibe.games/js/core/command.js",
+ "game/openvibe.games/js/core/timer.js",
+ "game/openvibe.games/js/gamemodes/base/server.js",
+ `game/openvibe.games/js/gamemodes/${mode}/server.js`,
+ ].forEach((file) => runFile(context, file));
+
+ const ply = makePlayer(messages);
+ context.gamemode.call("Initialize");
+ context.gamemode.call("MapInitialize", `${mode}_map`);
+ context.gamemode.call("PlayerInitialSpawn", ply);
+ context.gamemode.call("PlayerSpawn", ply);
+ context.gamemode.call("ConsoleCommand", commandName);
+ fakeTime += 1;
+ context.gamemode.call("Think");
+
+ if (!context.GAMEMODE || context.GAMEMODE.mode !== mode) {
+ throw new Error(`${mode}: GAMEMODE global was not set`);
+ }
+
+ if (!context.command.has(commandName)) {
+ throw new Error(`${mode}: missing command ${commandName}`);
+ }
+
+ if (!messages.some((line) => line.includes("JS loaded") || line.includes("Welcome"))) {
+ throw new Error(`${mode}: missing join/welcome output`);
+ }
+}
+
+console.log("[openvibe-smoke] all JS gamemodes loaded and handled lifecycle hooks");