openvibers/source.openvibe.games Commits

Add GMod-style JavaScript hook system

CO Copilot committed 29 Jun 2026 · 2c80f62f32 · 14 files, +762 −22


Changes

ddiff --git a/docs/OPENVIBE_JS_SCRIPTING.md b/docs/OPENVIBE_JS_SCRIPTING.md
nnew file mode 100644
iindex 0000000..dc584ec
---- /dev/null
++++ b/docs/OPENVIBE_JS_SCRIPTING.md
@@@ -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
++```
ddiff --git a/engine/openvibe-js-runtime/hook.js b/engine/openvibe-js-runtime/hook.js
iindex 693dbdb..3590f31 100644
---- a/engine/openvibe-js-runtime/hook.js
++++ b/engine/openvibe-js-runtime/hook.js
@@@ -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)
*/
ddiff --git a/game/openvibe.games/js/core/command.js b/game/openvibe.games/js/core/command.js
nnew file mode 100644
iindex 0000000..694cbee
---- /dev/null
++++ b/game/openvibe.games/js/core/command.js
@@@ -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;
++ });
++})();
ddiff --git a/game/openvibe.games/js/core/gamemode.js b/game/openvibe.games/js/core/gamemode.js
nnew file mode 100644
iindex 0000000..499e1fd
---- /dev/null
++++ b/game/openvibe.games/js/core/gamemode.js
@@@ -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;
++})();
ddiff --git a/game/openvibe.games/js/core/hook.js b/game/openvibe.games/js/core/hook.js
nnew file mode 100644
iindex 0000000..b65ee5c
---- /dev/null
++++ b/game/openvibe.games/js/core/hook.js
@@@ -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
++ };
++})();
ddiff --git a/game/openvibe.games/js/core/timer.js b/game/openvibe.games/js/core/timer.js
nnew file mode 100644
iindex 0000000..69f570d
---- /dev/null
++++ b/game/openvibe.games/js/core/timer.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/base/server.js b/game/openvibe.games/js/gamemodes/base/server.js
iindex c5cc71f..12294f9 100644
---- a/game/openvibe.games/js/gamemodes/base/server.js
++++ b/game/openvibe.games/js/gamemodes/base/server.js
@@@ -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 });
})();
ddiff --git a/game/openvibe.games/js/gamemodes/deathrun/server.js b/game/openvibe.games/js/gamemodes/deathrun/server.js
iindex b13432e..d7676fa 100644
---- a/game/openvibe.games/js/gamemodes/deathrun/server.js
++++ b/game/openvibe.games/js/gamemodes/deathrun/server.js
@@@ -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);
})();
ddiff --git a/game/openvibe.games/js/gamemodes/fortwars/server.js b/game/openvibe.games/js/gamemodes/fortwars/server.js
iindex db5c07c..6bd1fa9 100644
---- a/game/openvibe.games/js/gamemodes/fortwars/server.js
++++ b/game/openvibe.games/js/gamemodes/fortwars/server.js
@@@ -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);
})();
ddiff --git a/game/openvibe.games/js/gamemodes/hub/server.js b/game/openvibe.games/js/gamemodes/hub/server.js
iindex e2aebdd..8b892b9 100644
---- a/game/openvibe.games/js/gamemodes/hub/server.js
++++ b/game/openvibe.games/js/gamemodes/hub/server.js
@@@ -29,7 +29,7 @@
});
}
-- const HubServerGM = {
++ const GM = {
mode: "hub",
name: "OpenVibe Hub",
@@@ -63,5 +63,5 @@
Think() {}
};
-- gamemode.set(HubServerGM);
++ gamemode.set(GM);
})();
ddiff --git a/game/openvibe.games/js/gamemodes/prophunt/server.js b/game/openvibe.games/js/gamemodes/prophunt/server.js
iindex 8eebee6..24243ed 100644
---- a/game/openvibe.games/js/gamemodes/prophunt/server.js
++++ b/game/openvibe.games/js/gamemodes/prophunt/server.js
@@@ -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);
})();
ddiff --git a/game/openvibe.games/js/gamemodes/traitortown/server.js b/game/openvibe.games/js/gamemodes/traitortown/server.js
iindex 5ba0c47..4dc14be 100644
---- a/game/openvibe.games/js/gamemodes/traitortown/server.js
++++ b/game/openvibe.games/js/gamemodes/traitortown/server.js
@@@ -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);
})();
ddiff --git a/tools/smoke-js-core-node.mjs b/tools/smoke-js-core-node.mjs
iindex 80304c5..9ff65fa 100755
---- a/tools/smoke-js-core-node.mjs
++++ b/tools/smoke-js-core-node.mjs
@@@ -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");
ddiff --git a/tools/smoke-js-gamemodes.mjs b/tools/smoke-js-gamemodes.mjs
nnew file mode 100644
iindex 0000000..f689538
---- /dev/null
++++ b/tools/smoke-js-gamemodes.mjs
@@@ -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");