openvibers/source.openvibe.games Commits
Add GModJS platform: GMod-style JS framework, entity/net bridge, unified GUI with console+options, npm hotloading
Changes
@@@ -48,3 +48,5 @@ artifacts/_deps/*.tmp*.log++game/openvibe.games/js/node_modules/++game/openvibe.games/js/ov_downloads/@@@ -43,6 +43,15 @@ openvibe-source/| Prop Hunt disguise | Implemented | `ov_prophunt_disguise`, `ov_prophunt_reset_disguise` || Fort Wars placement | Implemented | `ov_fortwars_spawn` || VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |++| GModJS framework | Implemented | `game/openvibe.games/js/core/*` — hook/net/entity/ents/player/team/file/concommand/loader (GMod API shapes in JS); design in `docs/GMODJS_ARCHITECTURE.md`, guide in `docs/OPENVIBE_JS_SCRIPTING.md` |++| net library (JS) | Implemented | `js/core/net.js` — full GMod surface, pooling, chunked transport, per-player rate limits |++| Entity system (JS + native) | Implemented | `js/core/entity.js`, `js/core/ents.js`, `sdk/openvibe/shared/ov_js_entity.cpp` (`OV.entCreate`/`OV.entCall`); scripted entities in `js/entities/` |++| Client realm runtime | Implemented | `engine/openvibe-js-runtime/ov-runtime.js --realm client` + `sdk/openvibe/client/hl2mp/openvibe_js_client.cpp` |++| Script exec commands | Implemented | `js_run`, `js_openscript`, `js_run_cl`, `js_openscript_cl` (gated by `sv_allowcsjs`), `ov_npm` |++| AddCSJSFile + client sync | Implemented | `js/core/file.js` — manifest + net-based download into `js/ov_downloads/` |++| npm hotloading | Implemented | runtime control server `POST /npm`, `ov_npm`, fs.watch reload; `js/package.json` + `js/vendor/` |++| Unified GUI (launcher + in-game) | Implemented | `client/index.html` + `client/ui-sync.js` served to Electron (5173), backend (3000), and the CEF panel; routes incl. `console`, `options`, `hud` |++| In-game console + options GUI | Implemented | Console route (SSE logs, js_run/exec/npm), Options route (`openvibe://convar` bridge), HUD overlay (`OV_HudState`) || Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes || JS round system | Implemented | Base round state machine; Prop Hunt, Deathrun, Fort Wars, Traitor Town each with teams, timers, win conditions || Batch match rewards | Implemented | `POST /v1/matches/end/batch` — rewards multiple players in one idempotent call |@@@ -94,13 +103,32 @@ node tools/smoke-api.mjstools/dev-down.sh```--## Verified On June 30, 2026++GModJS framework (no C++ build needed):--- Backend TypeScript build passed.--- Backend Vitest suite passed: 13 tests.--- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.--- SRCDS smoke test passed for all five maps.--- Full dev stack registered all five servers and returned valid travel targets for every mode.++```bash++cd ~/src/openvibe-source++node tools/test-gmodjs.mjs # 131-check suite: hooks/net/entities/players/files/gamemodes++node tools/smoke-js-core-node.mjs++node tools/smoke-js-gamemodes.mjs++node tools/smoke-runtime-ipc.mjs # both Node runtimes over real TCP IPC++node tools/smoke-ui.mjs # unified GUI serving + route markers++```++++## Verified On July 2, 2026++++- Backend TypeScript build passed; Vitest suite passed: 14 tests.++- Source SDK 2013 Linux64 build passed with the OpenVibe C++ patch incl. the++ GModJS additions (`ov_js_entity.cpp`, `js_run`/`js_openscript`/`ov_npm`,++ client runtime events, `openvibe://cmd|convar` bridge, console spew tap).++- SRCDS smoke passed for all five maps with the rebuilt server.so; the++ embedded QuickJS boots the full GModJS core (net/entity/player/team/file/++ concommand/loader + addons + npm require).++- Live SRCDS console test: `js_run` evaluated in-engine, `js_openscript`++ ran a script from `js/`, `ents.Create("ov_bouncy_crate")` spawned and was++ queryable via `ents.FindByClass`.++- GModJS suite: 131/131 checks; runtime IPC smoke and UI smoke passed.++- npm hotloading verified live: `POST /npm install is-odd` into the running++ runtime, then `require("is-odd")(7)` returned true without a restart.## Production Readiness@@@ -514,6 +514,217 @@}.settings-input:focus { outline: none; border-color: var(--accent); }++ /* ── Electron titlebar (launcher chrome, shell=electron only) ── */++ .ov-titlebar {++ display: none;++ align-items: center;++ height: 34px;++ padding-left: 12px;++ gap: 8px;++ background: var(--bg-card);++ border-bottom: 1px solid var(--border);++ flex-shrink: 0;++ -webkit-app-region: drag;++ user-select: none;++ }++ html.ov-host-electron .ov-titlebar { display: flex; }++ .tb-title { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; color: var(--text-head); }++ .tb-sub { font-size: 11px; color: var(--text-dim); }++ .tb-controls { margin-left: auto; display: flex; -webkit-app-region: no-drag; }++ .tb-btn {++ width: 44px; height: 34px;++ border: none; background: none;++ color: var(--text-dim);++ font-size: 13px;++ cursor: pointer;++ transition: background var(--transition), color var(--transition);++ }++ .tb-btn:hover { background: var(--bg-hover); color: var(--text-head); }++ .tb-btn.close:hover { background: var(--red); color: #fff; }++++ /* Screens flex under the (optional) titlebar instead of fixed 100% height */++ .screen.active { flex: 1; min-height: 0; height: auto; }++++ /* ── Console route ────────────────────────────────── */++ :root { --console-font: 12px; --hud-scale: 1; }++++ .console-wrap { max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }++ .console-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }++ .console-spacer { flex: 1; }++++ .pill {++ font-size: 11px;++ font-weight: 600;++ padding: 2px 10px;++ border-radius: 12px;++ border: 1px solid var(--border);++ color: var(--text-dim);++ white-space: nowrap;++ }++ .pill.on { color: var(--green); border-color: rgba(76,175,80,0.4); background: rgba(76,175,80,0.1); }++ .pill.off { color: var(--red); border-color: rgba(229,57,53,0.4); background: rgba(229,57,53,0.08); }++ .pill.bridge { color: var(--orange, #ffb74d); border-color: rgba(255,183,77,0.4); background: rgba(255,183,77,0.08); }++++ .console-log {++ background: #04080c;++ border: 1px solid var(--border);++ border-radius: 8px;++ padding: 10px 12px;++ font-family: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;++ font-size: var(--console-font);++ line-height: 1.45;++ overflow-y: auto;++ height: calc(100vh - 320px);++ min-height: 220px;++ white-space: pre-wrap;++ word-break: break-word;++ }++ .console-line { color: var(--text); }++ .console-line.echo { color: var(--text-head); font-weight: 600; }++ .console-line.ok { color: var(--green); }++ .console-line.err { color: var(--red); }++ .console-line .tag { color: var(--text-dim); }++ .console-line .tag-server { color: var(--accent); }++ .console-line .tag-client { color: var(--gold); }++ .console-line .tag-engine { color: #a855f7; }++++ .console-input-row { display: flex; gap: 8px; align-items: center; }++ .console-prompt { color: var(--accent); font-weight: 700; font-family: ui-monospace, Consolas, monospace; }++ .console-input {++ flex: 1;++ background: var(--bg-card);++ border: 1px solid var(--border);++ border-radius: var(--radius);++ color: var(--text-head);++ padding: 7px 10px;++ font-family: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;++ font-size: var(--console-font);++ }++ .console-input:focus { outline: none; border-color: var(--accent); }++ .console-select {++ background: var(--bg-card);++ border: 1px solid var(--border);++ border-radius: var(--radius);++ color: var(--text);++ padding: 5px 8px;++ font-size: 12px;++ }++ .btn-console {++ padding: 5px 12px;++ font-size: 12px;++ font-weight: 600;++ background: rgba(26,159,255,0.1);++ border: 1px solid rgba(26,159,255,0.3);++ border-radius: var(--radius);++ color: var(--accent);++ cursor: pointer;++ transition: background var(--transition);++ white-space: nowrap;++ }++ .btn-console:hover { background: rgba(26,159,255,0.2); }++ .btn-console.toggled { background: rgba(245,166,35,0.15); border-color: rgba(245,166,35,0.4); color: var(--gold); }++++ /* ── Options route ────────────────────────────────── */++ .options-section { max-width: 560px; margin-bottom: 28px; }++ .options-title {++ font-size: 13px;++ font-weight: 700;++ text-transform: uppercase;++ letter-spacing: 0.06em;++ color: var(--text-dim);++ padding-bottom: 6px;++ border-bottom: 1px solid var(--border);++ margin-bottom: 4px;++ }++ .opt-note {++ font-size: 12px;++ color: var(--gold);++ background: rgba(245,166,35,0.08);++ border: 1px solid rgba(245,166,35,0.3);++ border-radius: var(--radius);++ padding: 8px 12px;++ margin-top: 10px;++ }++ .settings-input.narrow { width: 120px; }++ input[type="range"].settings-input { padding: 0; height: 24px; }++ input[type="color"].settings-input { width: 60px; height: 30px; padding: 2px; }++ .opt-inline { display: flex; gap: 8px; align-items: center; }++++ /* ── HUD overlay (#hud / ?hud=1) ──────────────────── */++ html.ov-hud-mode, body.ov-hud-mode { background: transparent !important; }++ #ov-hud {++ display: none;++ position: fixed;++ inset: 0;++ z-index: 5000;++ pointer-events: none;++ transform: scale(var(--hud-scale));++ transform-origin: center center;++ }++ body.ov-hud-mode #ov-hud { display: block; }++++ .hud-banner { position: absolute; top: 24px; left: 50%; transform: translateX(-50%); text-align: center; }++ .hud-round {++ display: inline-block;++ font-size: 20px;++ font-weight: 800;++ letter-spacing: 0.06em;++ color: #fff;++ text-shadow: 0 1px 4px rgba(0,0,0,0.8);++ background: rgba(7,14,20,0.45);++ border: 1px solid rgba(255,255,255,0.12);++ border-radius: 8px;++ padding: 6px 18px;++ }++ .hud-sub {++ margin-top: 6px;++ font-size: 13px;++ font-weight: 600;++ color: #cfe3f3;++ text-shadow: 0 1px 3px rgba(0,0,0,0.8);++ }++ .hud-health {++ position: absolute;++ left: 24px;++ bottom: 24px;++ display: flex;++ align-items: center;++ gap: 12px;++ }++ .hud-health-num {++ font-size: 34px;++ font-weight: 800;++ color: #fff;++ text-shadow: 0 1px 4px rgba(0,0,0,0.8);++ min-width: 60px;++ }++ .hud-health-bar {++ width: 180px;++ height: 10px;++ background: rgba(7,14,20,0.55);++ border: 1px solid rgba(255,255,255,0.15);++ border-radius: 5px;++ overflow: hidden;++ }++ .hud-health-fill {++ height: 100%;++ width: 100%;++ background: linear-gradient(90deg, var(--green), #8bc34a);++ transition: width 0.25s ease, background 0.25s ease;++ }++ .hud-health-fill.low { background: linear-gradient(90deg, var(--red), #ff7043); }++ .hud-gamemode {++ position: absolute;++ right: 24px;++ bottom: 24px;++ font-size: 12px;++ font-weight: 600;++ letter-spacing: 0.05em;++ text-transform: uppercase;++ color: #9fc3de;++ text-shadow: 0 1px 3px rgba(0,0,0,0.8);++ }++/* scrollbar */::-webkit-scrollbar { width: 6px; height: 6px; }::-webkit-scrollbar-track { background: var(--bg); }@@@ -523,6 +734,17 @@</head><body>++<!-- ── Electron launcher chrome (visible only when shell=electron) ────── -->++<div class="ov-titlebar" id="ov-titlebar">++ <span class="tb-title">OPENVIBE</span>++ <span class="tb-sub">: Source</span>++ <div class="tb-controls">++ <button class="tb-btn" id="tb-min" title="Minimise" onclick="OVApp.winMinimize()">─</button>++ <button class="tb-btn" id="tb-max" title="Maximise" onclick="OVApp.winMaximize()">□</button>++ <button class="tb-btn close" id="tb-close" title="Close" onclick="OVApp.winClose()">✕</button>++ </div>++</div>++<!-- ───────────────────────────────────────────────────────── AUTH SCREEN --><div id="ov-auth" class="screen active"><div class="auth-card">@@@ -532,7 +754,7 @@<div class="auth-divider"></div><div class="auth-label">Sign in to access all features and game modes.</div>-- <button id="btn-steam-auth" class="btn btn-steam" onclick="OV.steamAuth()">++ <button id="btn-steam-auth" class="btn btn-steam" onclick="OVApp.steamAuth()"><span class="steam-logo"></span>Sign in with Steam</button>@@@ -541,7 +763,7 @@<div id="devauth-section" style="display:none"><div class="auth-divider" style="margin-top:14px;"></div>-- <button id="btn-dev-auth" class="btn btn-dev" onclick="OV.devAuth()">++ <button id="btn-dev-auth" class="btn btn-dev" onclick="OVApp.devAuth()">Dev / Local auth (no Steam)</button></div>@@@ -561,6 +783,8 @@<button class="nav-tab" data-route="leaderboard">Leaderboard</button><button class="nav-tab" data-route="inventory">Inventory</button><button class="nav-tab" data-route="shop">Shop</button>++ <button class="nav-tab" data-route="options">Options</button>++ <button class="nav-tab" data-route="console">Console</button><button class="nav-tab" data-route="settings">Settings</button></nav>@@@ -569,7 +793,7 @@<span id="profile-name" class="profile-name">Player</span><span id="profile-currency" class="profile-currency">0 VC</span><span id="profile-xp" class="profile-xp">0 XP</span>-- <button class="btn-signout" onclick="OV.signOut()">Sign out</button>++ <button class="btn-signout" onclick="OVApp.signOut()">Sign out</button></div></div>@@@ -580,31 +804,31 @@<div id="route-portal" class="route active"><div class="section-header">Choose Your Game Mode</div><div class="modes-grid">-- <div class="mode-card" onclick="OV.joinMode('hub')">++ <div class="mode-card" onclick="OVApp.joinMode('hub')"><div class="mode-icon">🏠</div><div class="mode-title">Hub</div><div class="mode-desc">Central social hub. Meet other players, trade, and explore.</div><span class="mode-badge">Open Now</span></div>-- <div class="mode-card" onclick="OV.joinMode('prophunt')">++ <div class="mode-card" onclick="OVApp.joinMode('prophunt')"><div class="mode-icon">🎭</div><div class="mode-title">Prop Hunt</div><div class="mode-desc">Disguise as objects or hunt the hidden props before time runs out.</div><span class="mode-badge">Popular</span></div>-- <div class="mode-card" onclick="OV.joinMode('deathrun')">++ <div class="mode-card" onclick="OVApp.joinMode('deathrun')"><div class="mode-icon">💀</div><div class="mode-title">Deathrun</div><div class="mode-desc">Race through deadly obstacle courses. One wrong step and you're gone.</div><span class="mode-badge">Fast-paced</span></div>-- <div class="mode-card" onclick="OV.joinMode('fortwars')">++ <div class="mode-card" onclick="OVApp.joinMode('fortwars')"><div class="mode-icon">🏰</div><div class="mode-title">Fort Wars</div><div class="mode-desc">Build your fortress and defend against enemy raids.</div><span class="mode-badge">Team Play</span></div>-- <div class="mode-card" onclick="OV.joinMode('traitortown')">++ <div class="mode-card" onclick="OVApp.joinMode('traitortown')"><div class="mode-icon">🕵️</div><div class="mode-title">Traitor Town</div><div class="mode-desc">Trust no one. Identify and eliminate the hidden traitors.</div>@@@ -674,16 +898,174 @@</div><div class="settings-row" style="border:none; margin-top:8px;"><span></span>-- <button class="btn btn-dev" style="width:auto; padding: 6px 18px;" onclick="OV.signOut()">++ <button class="btn btn-dev" style="width:auto; padding: 6px 18px;" onclick="OVApp.signOut()">Sign out</button></div></div></div>++ <!-- Options: game/UI settings (account auth stays on #settings) -->++ <div id="route-options" class="route">++ <div class="section-header">Options</div>++++ <div class="options-section">++ <div class="options-title">Game</div>++ <div id="options-engine-hint" class="opt-note" style="display:none">++ Join a game to edit engine convars — these controls talk to the Source client in-game.++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Field of View</div>++ <div class="settings-desc">fov_desired</div>++ </div>++ <input class="settings-input narrow opt-convar" data-convar="fov_desired" type="number"++ min="54" max="120" step="1" onchange="OVApp.convarChanged(this)" />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Mouse Sensitivity</div>++ <div class="settings-desc">sensitivity</div>++ </div>++ <input class="settings-input narrow opt-convar" data-convar="sensitivity" type="number"++ min="0.1" max="20" step="0.05" onchange="OVApp.convarChanged(this)" />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Master Volume</div>++ <div class="settings-desc">volume</div>++ </div>++ <input class="settings-input opt-convar" data-convar="volume" type="range"++ min="0" max="1" step="0.05" onchange="OVApp.convarChanged(this)" />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Music Volume</div>++ <div class="settings-desc">snd_musicvolume</div>++ </div>++ <input class="settings-input opt-convar" data-convar="snd_musicvolume" type="range"++ min="0" max="1" step="0.05" onchange="OVApp.convarChanged(this)" />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Custom ov_* convar</div>++ <div class="settings-desc">Allowlisted: names starting with ov_</div>++ </div>++ <div class="opt-inline">++ <input id="opt-convar-name" class="settings-input narrow opt-convar" type="text" placeholder="ov_…" />++ <input id="opt-convar-value" class="settings-input narrow opt-convar" type="text" placeholder="value" />++ <button class="btn-console opt-convar" onclick="OVApp.setCustomConvar()">Set</button>++ </div>++ </div>++ </div>++++ <div class="options-section">++ <div class="options-title">UI</div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Theme Accent</div>++ <div class="settings-desc">Accent color used across the UI</div>++ </div>++ <input id="opt-ui-accent" class="settings-input" type="color" value="#1a9fff"++ onchange="OVApp.uiPrefChanged()" />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">HUD Scale</div>++ <div class="settings-desc">Size of the in-game HUD overlay</div>++ </div>++ <input id="opt-ui-hudscale" class="settings-input" type="range" min="0.7" max="1.5" step="0.05" value="1"++ onchange="OVApp.uiPrefChanged()" />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Console Font Size</div>++ <div class="settings-desc">px, applies to the Console route</div>++ </div>++ <input id="opt-ui-consolefont" class="settings-input narrow" type="number" min="10" max="20" step="1" value="12"++ onchange="OVApp.uiPrefChanged()" />++ </div>++ <div class="settings-row" style="border:none;">++ <span></span>++ <button class="btn-console" onclick="OVApp.resetUiPrefs()">Reset UI prefs</button>++ </div>++ </div>++++ <div class="options-section">++ <div class="options-title">Account</div>++ <div class="settings-row">++ <div>++ <div class="settings-label">Display Name</div>++ <div class="settings-desc">From your account (read-only)</div>++ </div>++ <input id="options-display-name" class="settings-input" type="text" readonly />++ </div>++ <div class="settings-row">++ <div>++ <div class="settings-label">API Endpoint</div>++ <div class="settings-desc">Backend API URL (advanced)</div>++ </div>++ <input id="options-api-url" class="settings-input" type="text" onchange="OVApp.saveApiUrl(this.value)" />++ </div>++ <div class="settings-row" style="border:none;">++ <span></span>++ <button class="btn-console" onclick="OVApp.routeTo('settings')">Open account settings</button>++ </div>++ </div>++++ <div class="options-section">++ <div class="options-title">Gamemode</div>++ <div id="options-gamemode">++ <div class="empty-state"><span class="spinner"></span> Checking active gamemode…</div>++ </div>++ </div>++ </div>++++ <!-- Console: unified JS/engine console -->++ <div id="route-console" class="route">++ <div class="section-header">Console</div>++ <div class="console-wrap">++ <div class="console-toolbar">++ <select id="console-realm" class="console-select" title="Execution realm">++ <option value="server">Server JS</option>++ <option value="client">Client JS</option>++ <option value="engine">Engine</option>++ </select>++ <span id="pill-server" class="pill off">server: off</span>++ <span id="pill-client" class="pill off">client: off</span>++ <span class="console-spacer"></span>++ <button class="btn-console" onclick="OVApp.consoleQuick('ov_js_status')">ov_js_status</button>++ <button class="btn-console" onclick="OVApp.consoleQuick('js_openscript')">js_openscript</button>++ <button class="btn-console" onclick="OVApp.consoleQuick('ov_npm ls')">ov_npm ls</button>++ <button id="console-pause" class="btn-console" onclick="OVApp.consolePause()">Pause scroll</button>++ <button class="btn-console" onclick="OVApp.consoleClear()">Clear</button>++ </div>++ <div id="console-log" class="console-log" aria-live="polite"></div>++ <div class="console-input-row">++ <span class="console-prompt">›</span>++ <input id="console-input" class="console-input" type="text" spellcheck="false" autocomplete="off"++ placeholder="JS code — or ]engine_command — or npm install <pkg>" />++ <button class="btn-console" onclick="OVApp.consoleSubmit()">Run</button>++ </div>++ </div>++ </div>++</div><!-- /content --></div><!-- /ov-main -->++<!-- ─────────────────────────────────────────────── HUD OVERLAY (#hud / ?hud=1) -->++<div id="ov-hud">++ <div class="hud-banner">++ <div id="hud-round" class="hud-round"> </div>++ <div class="hud-sub"><span id="hud-team">—</span> · <span id="hud-timer">--:--</span></div>++ </div>++ <div class="hud-health">++ <div id="hud-health-num" class="hud-health-num">100</div>++ <div class="hud-health-bar"><div id="hud-health-fill" class="hud-health-fill"></div></div>++ </div>++ <div id="hud-gamemode" class="hud-gamemode"></div>++</div>++<!-- Toast notification --><div id="ov-toast"></div>@@@ -691,12 +1073,27 @@(function () {'use strict';-- // ── Detect embedded Source engine context ─────────────────────────────++ // ── Detect host: Electron launcher / embedded Source panel / browser ──var params = new URLSearchParams(location.search);var embedded = params.get('embedded') === '1';var shell = params.get('shell') || '';++ // In Electron, window.OV is the preload IPC bridge (contextBridge) and is++ // already defined before this script runs. In-game / browser it is not,++ // and we install our own window.OV below.++ var electronOV = (window.OV && typeof window.OV.launchMode === 'function') ? window.OV : null;++ var isElectron = shell === 'electron' || params.get('electron') === '1' || !!electronOV;++ if (isElectron && !shell) shell = 'electron';++ var hudMode = params.get('hud') === '1' || location.hash === '#hud';var devMode = params.get('devauth') === '1' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';-- var apiBase = localStorage.getItem('ov_api_url') || (location.origin);++ // Served from the dev UI server (:5173) or Electron, the backend lives on :3000.++ var defaultApi = (isElectron || location.port === '5173') ? 'http://127.0.0.1:3000' : location.origin;++ var apiBase = localStorage.getItem('ov_api_url') || defaultApi;++++ // Runtime control servers (GModJS ov-runtime.js) per realm.++ var RT = {++ server: 'http://127.0.0.1:41997',++ client: 'http://127.0.0.1:41996'++ };// ── Internal state ────────────────────────────────────────────────────var _session = null; // { sessionToken, steamId, displayName, provider }@@@ -825,11 +1222,14 @@var _routeLoaded = {};function routeTo(routeName) {-- var valid = ['portal','servers','leaderboard','inventory','shop','settings'];++ if (routeName === 'hud') { enterHud(); return; }++ exitHud();++++ var valid = ['portal','servers','leaderboard','inventory','shop','settings','options','console'];if (valid.indexOf(routeName) === -1) routeName = 'portal';_currentRoute = routeName;-- location.replace('#' + routeName);++ if (location.hash !== '#' + routeName) location.replace('#' + routeName);document.querySelectorAll('.route').forEach(function (el) {el.classList.toggle('active', el.id === 'route-' + routeName);@@@ -838,11 +1238,12 @@el.classList.toggle('active', el.dataset.route === routeName);});-- if (!_routeLoaded[routeName]) {++ if (!_routeLoaded[routeName] || routeName === 'options') {_routeLoaded[routeName] = true;loadRoute(routeName);}}++ window.routeTo = routeTo; // used by ui-sync.js + ov_menu_* route commandsfunction loadRoute(route) {switch (route) {@@@ -850,6 +1251,8 @@case 'leaderboard': loadLeaderboard(); break;case 'inventory': loadInventory(); break;case 'shop': loadShop(); break;++ case 'console': consoleEnsure(); break;++ case 'options': loadOptions(); break;}}@@@ -880,7 +1283,7 @@'<div class="server-name">' + escHtml(s.mapName) + '</div>' +'<div class="server-players">' + s.playerCount + ' / ' + s.maxPlayers + '</div>' +'<span class="server-state ' + stateClass + '">' + escHtml(s.state) + '</span>' +-- (canJoin ? '<button class="btn-join-server" onclick="OV.joinMode(\'' + escHtml(s.mode) + '\')">Join</button>' : '<span></span>') +++ (canJoin ? '<button class="btn-join-server" onclick="OVApp.joinMode(\'' + escHtml(s.mode) + '\')">Join</button>' : '<span></span>') +'</div>';}).join('');}).catch(function () {@@@ -964,7 +1367,7 @@'<div class="item-price">' + (item.price === 0 ? 'Free' : item.price + ' VC') + '</div>' +(owned? '<span class="item-owned">✓ Owned</span>'-- : '<button class="btn-buy" onclick="OV.buyItem(' + JSON.stringify(item.itemId) + ',' + item.price + ')" ' +++ : '<button class="btn-buy" onclick="OVApp.buyItem(' + JSON.stringify(item.itemId) + ',' + item.price + ')" ' +(!_session ? 'disabled' : '') + '>Buy</button>') +'</div>';@@@ -984,8 +1387,456 @@.replace(/'/g, ''');}++ // ══════════════════════════════════════════════════════════════════════++ // Console route (#console) — SSE log merge + JS eval + engine commands++ // ══════════════════════════════════════════════════════════════════════++ var _con = {++ started: false,++ paused: false,++ history: [],++ hIdx: 0,++ es: {},++ retry: { server: 0, client: 0 },++ timers: {},++ up: { server: false, client: false } // runtime control servers reachable?++ };++++ // In-game the engine bridge (openvibe://cmd → js_run/js_run_cl, output back++ // through the console spew tap) covers JS eval even with no Node runtimes.++ function conHasEngineBridge() { return embedded && shell === 'source'; }++++ // state: 'live' (runtime ctrl server), 'bridge' (via engine), 'off'++ function conPill(tag, state) {++ _con.up[tag] = state === 'live';++ if (state === 'off' && conHasEngineBridge()) state = 'bridge';++ var el = document.getElementById('pill-' + tag);++ if (el) {++ el.className = 'pill ' + (state === 'live' ? 'on' : state === 'bridge' ? 'bridge' : 'off');++ el.textContent = tag + ': ' + (state === 'live' ? 'runtime' : state === 'bridge' ? 'engine' : 'off');++ }++ }++++ function conAppend(tag, text, cls) {++ var log = document.getElementById('console-log');++ if (!log) return;++ var line = document.createElement('div');++ line.className = 'console-line' + (cls ? ' ' + cls : '');++ var t = document.createElement('span');++ t.className = 'tag tag-' + tag;++ t.textContent = '[' + tag + '] ';++ line.appendChild(t);++ line.appendChild(document.createTextNode(String(text)));++ log.appendChild(line);++ while (log.childNodes.length > 2000) log.removeChild(log.firstChild);++ if (!_con.paused) log.scrollTop = log.scrollHeight;++ }++++ function conConnect(tag) {++ if (!('EventSource' in window)) { conPill(tag, 'off'); return; }++ try {++ var es = new EventSource(RT[tag] + '/logs');++ _con.es[tag] = es;++ es.onopen = function () {++ _con.retry[tag] = 0;++ conPill(tag, 'live');++ };++ es.onmessage = function (ev) {++ var text = ev.data;++ try {++ var obj = JSON.parse(ev.data);++ if (obj && obj.text !== undefined) {++ text = (obj.level && obj.level !== 'info' ? '[' + obj.level + '] ' : '') + obj.text;++ }++ } catch (e) { /* raw line */ }++ conAppend(tag, text);++ };++ es.onerror = function () {++ conPill(tag, 'off');++ try { es.close(); } catch (e) {}++ if (_con.es[tag] !== es) return; // superseded++ var delay = Math.min(30000, 1000 * Math.pow(2, _con.retry[tag]++));++ clearTimeout(_con.timers[tag]);++ _con.timers[tag] = setTimeout(function () {++ if (_con.started) conConnect(tag);++ }, delay);++ };++ } catch (e) {++ conPill(tag, 'off');++ }++ }++++ function consoleEnsure() {++ var input = document.getElementById('console-input');++ if (input) setTimeout(function () { input.focus(); }, 0);++ if (_con.started) return;++ _con.started = true;++ conConnect('server');++ conConnect('client');++ if (conHasEngineBridge()) {++ conAppend('engine', 'OpenVibe console ready. Engine output streams in below; Server/Client JS run via the runtimes when connected, otherwise through js_run / js_run_cl.', 'ok');++ } else {++ conAppend('engine', 'OpenVibe console ready — realms: Server JS (41997), Client JS (41996), Engine.', 'ok');++ }++++ if (input) {++ input.addEventListener('keydown', function (ev) {++ if (ev.key === 'Enter') {++ ev.preventDefault();++ consoleSubmit();++ } else if (ev.key === 'ArrowUp') {++ ev.preventDefault();++ if (_con.hIdx > 0) {++ _con.hIdx -= 1;++ input.value = _con.history[_con.hIdx] || '';++ }++ } else if (ev.key === 'ArrowDown') {++ ev.preventDefault();++ if (_con.hIdx < _con.history.length) {++ _con.hIdx += 1;++ input.value = _con.history[_con.hIdx] || '';++ }++ }++ });++ }++ }++++ function conEval(realm, code) {++ // Runtime control server offline? In-game we go through the engine bridge:++ // js_run / js_run_cl — the result comes back through the console spew tap.++ if (!_con.up[realm] && conHasEngineBridge()) {++ var cmd = (realm === 'client' ? 'js_run_cl ' : 'js_run ') + code;++ conAppend(realm, 'via engine: ' + cmd, 'echo');++ location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);++ return;++ }++ fetch(RT[realm] + '/eval', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ code: code })++ }).then(function (r) { return r.json(); }).then(function (res) {++ if (res && res.error) {++ var err = res.error;++ conAppend(realm, 'error: ' + (typeof err === 'object' ? (err.message || JSON.stringify(err)) : err), 'err');++ } else if (res && ('result' in res)) {++ conAppend(realm, '= ' + (typeof res.result === 'string' ? res.result : JSON.stringify(res.result)), 'ok');++ } else {++ conAppend(realm, '= ' + JSON.stringify(res), 'ok');++ }++ }).catch(function (e) {++ if (conHasEngineBridge()) {++ var cmd2 = (realm === 'client' ? 'js_run_cl ' : 'js_run ') + code;++ conAppend(realm, 'runtime unreachable — via engine: ' + cmd2, 'echo');++ location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd2);++ return;++ }++ conAppend(realm, 'eval failed (' + RT[realm] + '): ' + e.message + ' — start the runtime: node engine/openvibe-js-runtime/ov-runtime.js --realm ' + realm, 'err');++ });++ }++++ function conEngine(cmd) {++ if (!cmd) return;++ if (embedded && shell === 'source') {++ // In-game: allowlisted engine commands via the openvibe:// bridge.++ location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);++ conAppend('engine', 'sent: ' + cmd, 'ok');++ return;++ }++ // Electron / browser: forward through the server runtime control server.++ fetch(RT.server + '/exec', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ command: cmd })++ }).then(function (r) { return r.json(); }).then(function (res) {++ conAppend('engine', typeof res === 'string' ? res : JSON.stringify(res), 'ok');++ }).catch(function (e) {++ conAppend('engine', 'exec failed (' + RT.server + '): ' + e.message, 'err');++ });++ }++++ function conNpm(rest) {++ var args = rest.split(/\s+/).filter(Boolean);++ if (!_con.up.server && conHasEngineBridge()) {++ conAppend('server', 'via engine: ov_npm ' + args.join(' '), 'echo');++ location.href = 'openvibe://cmd?c=' + encodeURIComponent('ov_npm ' + args.join(' '));++ return;++ }++ conAppend('server', 'npm ' + args.join(' ') + ' …');++ fetch(RT.server + '/npm', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ args: args })++ }).then(function (r) { return r.json(); }).then(function (res) {++ conAppend('server', typeof res === 'string' ? res : JSON.stringify(res), 'ok');++ }).catch(function (e) {++ conAppend('server', 'npm failed (' + RT.server + '): ' + e.message, 'err');++ });++ }++++ function consoleSubmit() {++ var input = document.getElementById('console-input');++ if (!input) return;++ var line = input.value.trim();++ if (!line) return;++ _con.history.push(line);++ _con.hIdx = _con.history.length;++ input.value = '';++++ var realm = (document.getElementById('console-realm') || {}).value || 'server';++++ if (line.charAt(0) === ']') { // ]command → engine, any realm++ conAppend('engine', '› ' + line, 'echo');++ conEngine(line.slice(1).trim());++ return;++ }++ if (/^npm(\s|$)/.test(line)) { // npm … → runtime /npm++ conAppend('server', '› ' + line, 'echo');++ conNpm(line.replace(/^npm\s*/, ''));++ return;++ }++ if (realm === 'engine') {++ conAppend('engine', '› ' + line, 'echo');++ conEngine(line);++ return;++ }++ conAppend(realm, '› ' + line, 'echo');++ conEval(realm, line);++ }++++ // ══════════════════════════════════════════════════════════════════════++ // Options route (#options) — convars, UI prefs, account, gamemode++ // ══════════════════════════════════════════════════════════════════════++ var OPT_KEY = 'openvibe.options.v1';++ var CONVAR_ALLOWLIST = ['fov_desired', 'sensitivity', 'volume', 'snd_musicvolume'];++ var canEditConvars = embedded && shell === 'source';++++ function loadOptPrefs() {++ try {++ var raw = localStorage.getItem(OPT_KEY);++ var data = raw ? JSON.parse(raw) : {};++ return data && typeof data === 'object' ? data : {};++ } catch (e) { return {}; }++ }++++ function applyOptPrefs(prefs) {++ prefs = prefs || {};++ var rootStyle = document.documentElement.style;++ if (prefs.accent) rootStyle.setProperty('--accent', prefs.accent);++ if (prefs.hudScale) rootStyle.setProperty('--hud-scale', String(prefs.hudScale));++ if (prefs.consoleFont) rootStyle.setProperty('--console-font', prefs.consoleFont + 'px');++++ var accentEl = document.getElementById('opt-ui-accent');++ var scaleEl = document.getElementById('opt-ui-hudscale');++ var fontEl = document.getElementById('opt-ui-consolefont');++ if (accentEl && prefs.accent) accentEl.value = prefs.accent;++ if (scaleEl && prefs.hudScale) scaleEl.value = prefs.hudScale;++ if (fontEl && prefs.consoleFont) fontEl.value = prefs.consoleFont;++ }++++ function saveOptPrefs(prefs, broadcast) {++ try { localStorage.setItem(OPT_KEY, JSON.stringify(prefs)); } catch (e) {}++ applyOptPrefs(prefs);++ if (broadcast !== false && window.OpenVibeShell && window.OpenVibeShell.syncOptions) {++ window.OpenVibeShell.syncOptions(prefs);++ }++ }++++ function isAllowedConvar(name) {++ return CONVAR_ALLOWLIST.indexOf(name) !== -1 || /^ov_[a-z0-9_]+$/i.test(name);++ }++++ function setConvar(name, value) {++ if (!isAllowedConvar(name)) {++ toast('Convar not allowlisted: ' + name, 'error');++ return false;++ }++ if (!canEditConvars) {++ toast('Join a game to edit engine convars.', 'info');++ return false;++ }++ location.href = 'openvibe://convar?name=' + encodeURIComponent(name) +++ '&value=' + encodeURIComponent(String(value));++ return true;++ }++++ function requestConvars() {++ if (!canEditConvars) return;++ // Non-navigating bridge request (like openvibe://ready); C++ answers by++ // calling window.OV.onConvars({ name: value, … }).++ var img = new Image();++ img.src = 'openvibe://convar_get?names=' + encodeURIComponent(CONVAR_ALLOWLIST.join(','));++ }++++ function loadOptions() {++ // Engine section availability++ var hint = document.getElementById('options-engine-hint');++ if (hint) hint.style.display = canEditConvars ? 'none' : 'block';++ document.querySelectorAll('.opt-convar').forEach(function (el) {++ el.disabled = !canEditConvars;++ });++ requestConvars();++++ // UI prefs++ applyOptPrefs(loadOptPrefs());++++ // Account mirror (reuses the same storage/session as #settings)++ var apiEl = document.getElementById('options-api-url');++ if (apiEl) apiEl.value = apiBase;++ var nameEl = document.getElementById('options-display-name');++ if (nameEl) nameEl.value = (_session && _session.displayName) || '';++++ // Gamemode manifest settings from the server runtime++ loadGamemodeOptions();++ }++++ function renderGamemodeForm(el, modeName, settings) {++ var html = '<div class="settings-row" style="border-bottom:1px solid var(--border);">' +++ '<div><div class="settings-label">Active gamemode</div></div>' +++ '<span class="mode-badge">' + escHtml(modeName) + '</span></div>';++++ if (Array.isArray(settings) && settings.length) {++ html += settings.map(function (s, i) {++ var key = escHtml(s.name || s.key || ('setting_' + i));++ var label = escHtml(s.label || s.title || s.name || ('Setting ' + i));++ var type = String(s.type || 'text').toLowerCase();++ var value = s.value != null ? s.value : (s.default != null ? s.default : '');++ var input;++ if (type === 'bool' || type === 'boolean' || type === 'checkbox') {++ input = '<input class="settings-input narrow gm-setting" data-gm="' + key + '" type="checkbox" ' + (value ? 'checked' : '') + ' />';++ } else if (type === 'number' || type === 'int' || type === 'float') {++ input = '<input class="settings-input narrow gm-setting" data-gm="' + key + '" type="number" value="' + escHtml(String(value)) + '"' +++ (s.min != null ? ' min="' + Number(s.min) + '"' : '') +++ (s.max != null ? ' max="' + Number(s.max) + '"' : '') + ' />';++ } else if (type === 'select' && Array.isArray(s.options)) {++ input = '<select class="settings-input narrow gm-setting" data-gm="' + key + '">' +++ s.options.map(function (o) {++ var ov = typeof o === 'object' ? (o.value != null ? o.value : o.label) : o;++ var ol = typeof o === 'object' ? (o.label != null ? o.label : o.value) : o;++ return '<option value="' + escHtml(String(ov)) + '"' + (String(ov) === String(value) ? ' selected' : '') + '>' + escHtml(String(ol)) + '</option>';++ }).join('') + '</select>';++ } else {++ input = '<input class="settings-input gm-setting" data-gm="' + key + '" type="text" value="' + escHtml(String(value)) + '" />';++ }++ return '<div class="settings-row"><div><div class="settings-label">' + label + '</div>' +++ (s.description ? '<div class="settings-desc">' + escHtml(s.description) + '</div>' : '') +++ '</div>' + input + '</div>';++ }).join('');++ } else {++ html += '<div class="empty-state">This gamemode exposes no settings.</div>';++ }++ el.innerHTML = html;++++ el.querySelectorAll('.gm-setting').forEach(function (input) {++ input.addEventListener('change', function () {++ var name = input.dataset.gm;++ var value = input.type === 'checkbox' ? (input.checked ? 1 : 0) : input.value;++ conEngine('ov_gm_set ' + name + ' ' + value);++ toast('Gamemode setting sent: ' + name, 'success');++ });++ });++ }++++ function loadGamemodeOptions() {++ var el = document.getElementById('options-gamemode');++ if (!el) return;++ fetch(RT.server + '/state').then(function (r) { return r.json(); }).then(function (st) {++ st = st || {};++ var gm = st.gamemode != null ? st.gamemode : (st.mode != null ? st.mode : (st.state && st.state.gamemode));++ if (!gm) {++ el.innerHTML = '<div class="empty-state">No active gamemode reported by the runtime.</div>';++ return;++ }++ var modeName = typeof gm === 'object' ? (gm.title || gm.name || 'unknown') : String(gm);++ var settings = (typeof gm === 'object' && Array.isArray(gm.settings)) ? gm.settings++ : (Array.isArray(st.settings) ? st.settings++ : (st.manifest && Array.isArray(st.manifest.settings) ? st.manifest.settings : null));++ renderGamemodeForm(el, modeName, settings);++ }).catch(function () {++ el.innerHTML = '<div class="empty-state">Server JS runtime offline (' + escHtml(RT.server) +++ ') — start a game server to configure the active gamemode.</div>';++ });++ }++++ // ══════════════════════════════════════════════════════════════════════++ // HUD overlay (#hud / ?hud=1) — translucent in-game overlay++ // ══════════════════════════════════════════════════════════════════════++ var _hudActive = false;++ var _hudPollTimer = null;++++ function fmtTimer(v) {++ if (v == null || v === '') return '--:--';++ var n = Number(v);++ if (isNaN(n)) return String(v);++ n = Math.max(0, Math.floor(n));++ var m = Math.floor(n / 60);++ var s = n % 60;++ return m + ':' + (s < 10 ? '0' : '') + s;++ }++++ function applyHudState(st) {++ if (!st || typeof st !== 'object') return;++ var round = st.round != null ? st.round : (st.roundState != null ? st.roundState : st.state);++ var team = st.team != null ? st.team : (st.teamName != null ? st.teamName : null);++ var timer = st.timer != null ? st.timer : (st.timeLeft != null ? st.timeLeft : st.time);++ var hp = st.health != null ? st.health : st.hp;++ var mode = st.gamemode != null ? st.gamemode : st.mode;++++ var el;++ if (round != null && (el = document.getElementById('hud-round'))) el.textContent = String(round);++ if (team != null && (el = document.getElementById('hud-team'))) el.textContent = String(team);++ if (timer != null && (el = document.getElementById('hud-timer'))) el.textContent = fmtTimer(timer);++ if (mode != null && (el = document.getElementById('hud-gamemode'))) {++ el.textContent = typeof mode === 'object' ? (mode.title || mode.name || '') : String(mode);++ }++ if (hp != null) {++ var n = Math.max(0, Math.round(Number(hp) || 0));++ var num = document.getElementById('hud-health-num');++ var fill = document.getElementById('hud-health-fill');++ if (num) num.textContent = String(n);++ if (fill) {++ fill.style.width = Math.min(100, n) + '%';++ fill.className = 'hud-health-fill' + (n <= 25 ? ' low' : '');++ }++ }++ }++++ function hudPollOnce() {++ // Client runtime first (has LocalPlayer data), server runtime as fallback.++ fetch(RT.client + '/state')++ .then(function (r) { return r.json(); })++ .then(applyHudState)++ .catch(function () {++ fetch(RT.server + '/state')++ .then(function (r) { return r.json(); })++ .then(applyHudState)++ .catch(function () {});++ });++ }++++ function enterHud() {++ if (_hudActive) return;++ _hudActive = true;++ document.documentElement.classList.add('ov-hud-mode');++ document.body.classList.add('ov-hud-mode');++ document.getElementById('ov-auth').classList.remove('active');++ document.getElementById('ov-main').classList.remove('active');++ if (location.hash !== '#hud') location.replace('#hud');++ hudPollOnce();++ _hudPollTimer = setInterval(hudPollOnce, 2000);++ }++++ function exitHud() {++ if (!_hudActive) return;++ _hudActive = false;++ document.documentElement.classList.remove('ov-hud-mode');++ document.body.classList.remove('ov-hud-mode');++ clearInterval(_hudPollTimer);++ _hudPollTimer = null;++ if (_session) document.getElementById('ov-main').classList.add('active');++ else document.getElementById('ov-auth').classList.add('active');++ }++// ── Public OV API (called by C++ and by page itself) ──────────────────-- window.OV = {++ var OVApp = {// Called by C++ after GetTicketForWebApi + POST /v1/auth/steam succeed.// data: { authenticated, sessionToken, steamId, displayName, error? }@@@ -1056,8 +1907,18 @@});},-- // Join a game mode — uses openvibe:// bridge in-game, API otherwise.++ // Join a game mode — Electron launches the game, in-game uses the++ // openvibe:// bridge, plain browser just explains.joinMode: function (mode) {++ if (electronOV && electronOV.launchMode) {++ toast('Launching ' + mode + '…', 'info', 4000);++ Promise.resolve(electronOV.launchMode(mode)).then(function (ok) {++ if (ok === false) toast('Launch failed — game binary not found. Build the SDK first.', 'error', 5000);++ }).catch(function (e) {++ toast('Launch failed: ' + e.message, 'error', 5000);++ });++ return;++ }if (embedded && shell === 'source') {location.href = 'openvibe://join?mode=' + encodeURIComponent(mode);} else {@@@ -1104,11 +1965,170 @@// Called by C++ when the ready bridge is sent (openvibe://ready).// C++ uses this to inject stored session tokens on page reload.-- onReady: function () {}++ onReady: function () {},++++ // ── Navigation / window chrome ────────────────────────────────────++ routeTo: routeTo,++ winMinimize: function () { if (electronOV && electronOV.minimize) electronOV.minimize(); },++ winMaximize: function () { if (electronOV && electronOV.maximize) electronOV.maximize(); },++ winClose: function () {++ if (electronOV && electronOV.close) electronOV.close();++ else location.href = 'openvibe://close';++ },++++ // ── Console ───────────────────────────────────────────────────────++ consoleSubmit: consoleSubmit,++ consoleClear: function () {++ var log = document.getElementById('console-log');++ if (log) log.innerHTML = '';++ },++ consolePause: function () {++ _con.paused = !_con.paused;++ var btn = document.getElementById('console-pause');++ if (btn) {++ btn.classList.toggle('toggled', _con.paused);++ btn.textContent = _con.paused ? 'Resume scroll' : 'Pause scroll';++ }++ if (!_con.paused) {++ var log = document.getElementById('console-log');++ if (log) log.scrollTop = log.scrollHeight;++ }++ },++ consoleQuick: function (cmd) {++ consoleEnsure();++ conAppend('engine', '› ' + cmd, 'echo');++ conEngine(cmd);++ },++++ // Called by the C++ console spew tap (vgui_openvibe_menu.cpp) to push++ // engine console output into the console view. Accepts a string or an++ // array of lines.++ onConsoleLine: function (lines) {++ if (lines == null) return;++ var arr = Array.isArray(lines) ? lines : [lines];++ for (var i = 0; i < arr.length; i++) conAppend('engine', arr[i]);++ },++++ // ── Options ───────────────────────────────────────────────────────++ // Called by C++ with current convar values, requested via++ // openvibe://convar_get?names=… → { fov_desired: "90", … }++ onConvars: function (obj) {++ if (!obj || typeof obj !== 'object') return;++ document.querySelectorAll('.opt-convar[data-convar]').forEach(function (el) {++ var name = el.dataset.convar;++ if (name in obj) el.value = obj[name];++ });++ },++++ convarChanged: function (el) {++ if (!el || !el.dataset.convar) return;++ if (setConvar(el.dataset.convar, el.value)) {++ toast(el.dataset.convar + ' = ' + el.value, 'success');++ }++ },++++ setCustomConvar: function () {++ var nameEl = document.getElementById('opt-convar-name');++ var valEl = document.getElementById('opt-convar-value');++ var name = nameEl ? nameEl.value.trim() : '';++ if (!name) return;++ if (!/^ov_[a-z0-9_]+$/i.test(name)) {++ toast('Only ov_* convars are allowed here.', 'error');++ return;++ }++ if (setConvar(name, valEl ? valEl.value : '')) {++ toast(name + ' = ' + (valEl ? valEl.value : ''), 'success');++ }++ },++++ uiPrefChanged: function () {++ var prefs = loadOptPrefs();++ var accentEl = document.getElementById('opt-ui-accent');++ var scaleEl = document.getElementById('opt-ui-hudscale');++ var fontEl = document.getElementById('opt-ui-consolefont');++ if (accentEl) prefs.accent = accentEl.value;++ if (scaleEl) prefs.hudScale = Number(scaleEl.value) || 1;++ if (fontEl) prefs.consoleFont = Math.min(20, Math.max(10, Number(fontEl.value) || 12));++ saveOptPrefs(prefs);++ toast('UI preferences saved.', 'success');++ },++++ resetUiPrefs: function () {++ saveOptPrefs({ accent: '#1a9fff', hudScale: 1, consoleFont: 12 });++ toast('UI preferences reset.', 'success');++ },++++ // Applied when the other host broadcasts option changes (ui-sync.js).++ applyOptions: function (prefs, fromRemote) {++ if (!prefs || typeof prefs !== 'object') return;++ if (fromRemote) {++ try { localStorage.setItem(OPT_KEY, JSON.stringify(prefs)); } catch (e) {}++ applyOptPrefs(prefs);++ } else {++ saveOptPrefs(prefs);++ }++ },++++ saveApiUrl: function (value) {++ if (!value) return;++ apiBase = String(value).replace(/\/+$/, '');++ try { localStorage.setItem('ov_api_url', apiBase); } catch (e) {}++ var apiInput = document.getElementById('settings-api-url');++ if (apiInput) apiInput.value = apiBase;++ var optInput = document.getElementById('options-api-url');++ if (optInput) optInput.value = apiBase;++ toast('API endpoint saved.', 'success');++ },++++ // ── HUD ───────────────────────────────────────────────────────────++ // Called via ov_menu_js push (client runtime → C++ → this page) with++ // { round, team, timer, health, gamemode }. Poll of /state is fallback.++ onHudState: function (obj) {++ applyHudState(obj);++ }};++ // Expose the app API. In-game / browser: window.OV is this object and C++++ // calls window.OV.onSteamAuthResult / onConsoleLine / onConvars /++ // onHudState on it. In Electron, window.OV is the preload IPC bridge++ // (read-only contextBridge) and must be preserved — the app is reachable++ // as window.OVApp in both hosts.++ window.OVApp = OVApp;++ if (!electronOV) {++ window.OV = OVApp;++ }++// ── Startup: check stored session ─────────────────────────────────────function init() {++ // UI prefs (accent / hud scale / console font) apply in every host.++ applyOptPrefs(loadOptPrefs());++++ // Electron launcher wiring (preload IPC bridge).++ if (electronOV) {++ if (electronOV.onRoute) electronOV.onRoute(function (route) { routeTo(route); });++ if (electronOV.onGameStart) electronOV.onGameStart(function (pid) {++ toast('Game launched' + (pid ? ' (pid ' + pid + ')' : '') + '.', 'success');++ });++ if (electronOV.onGameExit) electronOV.onGameExit(function (code) {++ toast('Game exited (code ' + code + ').', 'info');++ });++ if (electronOV.onLaunchState) electronOV.onLaunchState(function (state) {++ if (state && state.message && state.phase && state.phase !== 'idle') {++ toast(state.message, 'info', 4000);++ }++ });++ }++++ // HUD overlay mode: no auth, no chrome — just the translucent overlay.++ if (hudMode) {++ enterHud();++ if (embedded && shell === 'source') {++ var hudReady = new Image();++ hudReady.src = 'openvibe://ready';++ }++ return;++ }++if (devMode) {var devSection = document.getElementById('devauth-section');if (devSection) devSection.style.display = 'block';@@@ -1144,5 +2164,7 @@})();</script>++<!-- Route/state sync shared with the other host (localStorage + BroadcastChannel) -->++<script src="ui-sync.js"></script></body></html>nnew file mode 100644@@@ -0,0 +1,253 @@++/*++ * OpenVibe unified shell sync (client/ui-sync.js).++ *++ * Runs in every host of the unified app:++ * - Electron launcher Chromium (?electron=1&shell=electron)++ * - Source/VGUI embedded panel (?embedded=1&shell=source)++ * - Plain browser (backend :3000 /client/, or dev server :5173)++ *++ * Keeps the route/state contract identical across hosts:++ * - location.hash++ * - localStorage["openvibe.ui.state.v1"]++ * - BroadcastChannel("openvibe-ui-sync-v1")++ *++ * Adapted from launcher/openvibe-ui-sync.js with the extended route set++ * (options, console, hud) for the GModJS platform.++ */++(() => {++ 'use strict';++++ const STORAGE_KEY = 'openvibe.ui.state.v1';++ const CHANNEL_NAME = 'openvibe-ui-sync-v1';++ const ROUTES = new Set([++ 'portal', 'servers', 'leaderboard', 'inventory', 'shop',++ 'settings', 'options', 'console', 'hud',++ ]);++++ const url = new URL(window.location.href);++ // window.OV exists in BOTH hosts (preload bridge in Electron, inline app++ // object in-game), so detect Electron by query params / preload-only API.++ const isElectron =++ url.searchParams.get('shell') === 'electron' ||++ url.searchParams.get('electron') === '1' ||++ !!(window.OV && typeof window.OV.launchMode === 'function' && typeof window.OV.minimize === 'function');++ const isEmbedded =++ url.searchParams.get('embedded') === '1' ||++ url.searchParams.get('shell') === 'source';++++ let channel = null;++ try {++ channel = 'BroadcastChannel' in window ? new BroadcastChannel(CHANNEL_NAME) : null;++ } catch {++ channel = null;++ }++++ function readState() {++ try {++ const raw = window.localStorage.getItem(STORAGE_KEY);++ if (!raw) return {};++ const data = JSON.parse(raw);++ return data && typeof data === 'object' ? data : {};++ } catch {++ return {};++ }++ }++++ function writeState(patch, broadcast = true) {++ const next = {++ ...readState(),++ ...patch,++ updatedAt: Date.now(),++ host: isElectron ? 'electron' : 'source'++ };++ try {++ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));++ } catch {}++++ if (broadcast && channel) {++ try {++ channel.postMessage({ type: 'state', state: next });++ } catch {}++ }++++ return next;++ }++++ function routeFromHash() {++ const hash = (window.location.hash || '').replace(/^#\/?/, '').trim();++ const first = hash.split(/[/?&]/)[0] || '';++ return ROUTES.has(first) ? first : null;++ }++++ function routeFromQuery() {++ if (url.searchParams.get('hud') === '1') return 'hud';++ const route = url.searchParams.get('route');++ return ROUTES.has(route) ? route : null;++ }++++ function currentRoute() {++ return routeFromHash() || routeFromQuery() || readState().route || 'portal';++ }++++ function applyRouteToDom(route) {++ // Unified app exposes routeTo (client/index.html); prefer it.++ if (typeof window.routeTo === 'function') {++ try { window.routeTo(route); return; } catch {}++ }++ if (typeof window.setTab === 'function') {++ try { window.setTab(route); return; } catch {}++ }++ // Generic fallback for both markup dialects.++ document.querySelectorAll('.nav-tab').forEach((b) => {++ b.classList.toggle('active', b.dataset.route === route);++ });++ document.querySelectorAll('.route').forEach((p) => {++ p.classList.toggle('active', p.id === `route-${route}`);++ });++ document.querySelectorAll('.nav-btn').forEach((b) => {++ b.classList.toggle('active', b.dataset.tab === route);++ });++ document.querySelectorAll('.tab-panel').forEach((p) => {++ p.classList.toggle('active', p.id === `tab-${route}`);++ });++ }++++ function setRoute(route, opts = {}) {++ if (!ROUTES.has(route)) route = 'portal';++++ applyRouteToDom(route);++++ if (!opts.silentHash && window.location.hash !== `#${route}`) {++ history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${route}`);++ }++++ writeState({ route, embedded: isEmbedded, electron: isElectron }, !opts.fromRemote);++++ const evt = new CustomEvent('openvibe:route', { detail: { route, host: isElectron ? 'electron' : 'source' } });++ window.dispatchEvent(evt);++ }++++ function installKeyboard() {++ window.addEventListener('keydown', (ev) => {++ const key = (ev.key || '').toLowerCase();++ const target = ev.target;++ const typing = !!(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable));++++ if (key === 'escape' && isEmbedded) {++ ev.preventDefault();++ window.location.href = 'openvibe://close';++ return;++ }++++ if (ev.ctrlKey && key === 'r') {++ ev.preventDefault();++ if (isEmbedded) window.location.href = 'openvibe://reload';++ else window.location.reload();++ return;++ }++++ // ` (backquote) or F10 toggles the console route.++ if (key === '`' || key === 'f10') {++ if (key === '`' && typing) return; // let people type backticks++ ev.preventDefault();++ setRoute(currentRoute() === 'console' ? 'portal' : 'console');++ return;++ }++++ if (typing) return;++++ if (key === 'f1') {++ ev.preventDefault();++ setRoute('portal');++ }++++ if (key === 'f2') {++ ev.preventDefault();++ setRoute('servers');++ }++ });++ }++++ function installBridge() {++ window.OpenVibeShell = {++ isElectron,++ isEmbedded,++ routes: Array.from(ROUTES),++ getState: readState,++ setRoute,++ // Sync UI prefs (openvibe.options.v1) to the other host.++ syncOptions(options) {++ if (!options || typeof options !== 'object') return;++ writeState({ options });++ },++ openMode(mode) {++ if (isElectron && window.OV?.launchMode) {++ return window.OV.launchMode(mode);++ }++ window.location.href = `openvibe://join?mode=${encodeURIComponent(mode)}`;++ return true;++ },++ close() {++ if (isElectron && window.OV?.close) window.OV.close();++ else window.location.href = 'openvibe://close';++ },++ reload() {++ if (isEmbedded) window.location.href = 'openvibe://reload';++ else window.location.reload();++ }++ };++ }++++ function applyRemoteState(state) {++ if (!state || typeof state !== 'object') return;++ if (state.options && window.OVApp && typeof window.OVApp.applyOptions === 'function') {++ try { window.OVApp.applyOptions(state.options, true); } catch {}++ }++ // Never yank the other host into HUD-overlay mode remotely.++ if (ROUTES.has(state.route) && state.route !== 'hud') {++ setRoute(state.route, { fromRemote: true, silentHash: false });++ }++ }++++ function installRemoteState() {++ if (channel) {++ channel.onmessage = (ev) => {++ const msg = ev.data || {};++ if (msg.type !== 'state' || !msg.state) return;++ if (msg.state.host === (isElectron ? 'electron' : 'source')) return;++ applyRemoteState(msg.state);++ };++ }++++ window.addEventListener('storage', (ev) => {++ if (ev.key !== STORAGE_KEY || !ev.newValue) return;++ try {++ applyRemoteState(JSON.parse(ev.newValue));++ } catch {}++ });++ }++++ function boot() {++ document.documentElement.classList.toggle('ov-host-electron', isElectron);++ document.documentElement.classList.toggle('ov-host-source', !isElectron);++ document.documentElement.classList.toggle('ov-embedded', isEmbedded);++++ installBridge();++ installKeyboard();++ installRemoteState();++++ setRoute(currentRoute(), { silentHash: false });++++ window.addEventListener('hashchange', () => {++ setRoute(currentRoute(), { silentHash: true });++ });++++ console.log(`[OpenVibeShell] ready host=${isElectron ? 'electron' : 'source'} embedded=${isEmbedded} route=${currentRoute()}`);++ }++++ if (document.readyState === 'loading') {++ document.addEventListener('DOMContentLoaded', boot);++ } else {++ boot();++ }++})();nnew file mode 100644@@@ -0,0 +1,183 @@++# OpenVibe GModJS Architecture++++Goal: a full GMod-style scripting platform for OpenVibe: Source — GMod Lua API++shapes, implemented in JavaScript, with npm integration, hotloading, and an++HTML/JS/CSS GUI that is shared between the desktop launcher and the in-game++menu.++++This document is the blueprint for the framework in `game/openvibe.games/js/`++plus its C++ bridge (`sdk/openvibe/`) and the unified web GUI (`client/`).++++## Realms++++Mirrors GMod's states (https://wiki.facepunch.com/gmod/States):++++| Realm | Global | Where it runs |++| ------ | ------------- | ------------- |++| server | `SERVER=true` | QuickJS embedded in server DLL (`ov_js_backend embedded`) or `ov-runtime.js --realm server` (`node` backend, TCP 41999) |++| client | `CLIENT=true` | `ov-runtime.js --realm client` (TCP 41998; embedded client QuickJS was abandoned — clang-cl crash) |++| menu | `MENU=true` | The HTML GUI itself (Electron renderer / in-game CEF panel) |++++Realm globals are published by `js/core/realm.js` from `OV.isServer()`.++Client<->server talk only via the `net` library. The menu talks to the game++via `openvibe://` bridge actions and to the runtimes via the runtime control++HTTP server (see Console).++++## File layout (GMod-parity)++++```text++game/openvibe.games/js/++ core/ hook, gamemode, net, timer, command, concommand, module,++ addon, realm, file, entity, ents, scripted_ents, nwvars,++ player_ext, util++ autorun/ *.js run on both realms (auto-networked to clients)++ autorun/server/ *.js server only++ autorun/client/ *.js client only (auto-networked)++ entities/<class>.js single-file scripted entity (shared; guard with SERVER/CLIENT)++ entities/<class>/{shared,init,cl_init}.js folder-form scripted entity++ gamemodes/<name>/manifest.json GMod <name>.txt equivalent (base, title, settings[])++ gamemodes/<name>/shared.js both realms++ gamemodes/<name>/server.js server entry (GMod init.lua)++ gamemodes/<name>/client.js client entry (GMod cl_init.lua, auto-networked)++ node_modules/ npm packages (require()-able from any script)++addons/<name>/{addon.json,shared.js,server.js,client.js,entities/,autorun/}++```++++Loading order (per realm, GMod Lua_Loading_Order-faithful):++core -> autorun (alphabetical; shared then realm dir) -> base gamemode ->++active gamemode (shared -> realm entry) -> scripted entities -> addons ->++`Initialize` -> `InitPostEntity`.++++## Hook library (js/core/hook.js)++++GMod semantics (Hook_Library_Usage):++- `hook.Add(event, id, fn)` — id is a string OR an object with `IsValid()`;++ invalid-object hooks are auto-removed at call time; object id is prepended++ as first arg (self).++- First non-`undefined` return short-circuits (false counts, undefined does++ not); hooks run before the `GAMEMODE` method; `hook.Run` = `hook.Call`++ with the active gamemode table.++++## net library (js/core/net.js)++++Full GMod surface: `Start(name, unreliable)`, writers/readers for++Bit/Bool/Int/UInt/UInt64/Float/Double/String/Data/Vector/Normal/Angle/Color/++Entity/Player/Table/Type, `Send/Broadcast/SendOmit/SendPVS/SendPAS/++SendToServer/Abort`, `BytesWritten/BytesLeft`, `Receive` (single handler,++name lowercased), `util.AddNetworkString/NetworkStringToID/IDToString`.++++Faithful behaviors: 64KB payload cap enforced, read-past-end returns++defaults, server receivers get `(len, ply)` with authoritative sender,++per-player per-message rate limiting (`net.SetRateLimit`), names must be++pooled server-side before `Start`.++++Transport (unchanged wire): serialized typed-field JSON, base64. S->C =++`OV.netEmit` -> `OVNet` usermessage -> client bridge -> client runtime.++C->S = `ov_net <name> <payload>` forwarded command -> `OVNetReceive` hook.++Payloads over the usermessage limit are chunked (`OVNetChunk`).++++## Entities (js/core/entity.js, ents.js, scripted_ents.js, nwvars.js)++++- `Entity` wrapper over `{entIndex, serial}` handles; `NULL` singleton;++ `IsValid()`; `Remove()` deferred to end of tick firing `CallOnRemove` then++ `OnRemove`. Server methods call native bindings (`OV.ent*`); everything++ degrades gracefully in the Node harness (pure-JS entity registry) so++ gamemodes are testable without the engine.++- `ents.Create/FindByClass(wildcards)/FindInSphere/GetAll/Iterator/++ GetByIndex/GetCount`, client `ents.CreateClientside`.++- `scripted_ents.Register(ENT, class)` with `Base` prototype chains and++ hot-repatch of live instances on re-register; lifecycle:++ `SetupDataTables -> KeyValue -> Initialize -> Think(NextThink) -> Use/++ Touch/OnTakeDamage -> OnRemove`.++- Networked state: `SetNW*/GetNW*` keyed store + `NetworkVar` DTVar-lite++ slots declared in `SetupDataTables`; server changes replicate to clients++ via internal `OV_NWSync` net messages; `SetNWVarProxy`/`NetworkVarNotify`.++- `Player` extends `Entity` (Nick/SteamID/UserID/Team/Kill/Give/Freeze/++ ChatPrint/...); `player.GetAll/GetByUserID/Iterator`; `LocalPlayer()` on++ the client.++++## File networking (js/core/file.js) — AddCSLuaFile equivalents++++- `AddCSJSFile(path?)` (alias `AddCSLuaFile` for muscle memory) — server-only++ effect; default = currently loading file. Auto-networked: `autorun/`,++ `autorun/client/`, gamemode `shared.js`/`client.js`, `entities/`.++- Server builds a manifest {path, sha256, size}; on `ClientFullyConnected`++ it net-sends the manifest; the client runtime diffs against its cache++ (`js/ov_downloads/`), requests missing files via `OV_FileRequest`, and++ loads client scripts from the synced set. `include(path)` executes a file++ in the caller realm with caller-dir relative resolution.++++## Console commands (C++ + js/core/concommand.js)++++- `concommand.Add(name, fn(ply, cmd, args, argStr))` per realm.++- Server: `js_run <code>`, `js_openscript <path>` (path relative to `js/`).++- Client: `js_run_cl <code>`, `js_openscript_cl <path>` — gated by replicated++ `sv_allowcsjs` (GMod's sv_allowcslua), never executable via the++ server->client command channel.++- Existing `ov_js_*` commands remain. `ov_npm <install|update> <pkg>` routes++ to the Node runtime which shells out to npm in `js/` then hot-reloads.++++## Hotloading++++- Node backend: `fs.watch` over `js/` + `addons/` (exists) -> targeted++ `require.reload` + `OnReloaded` hook; scripted entity re-register++ hot-patches live instances.++- Embedded backend: `ov_js_reload` full reload; `js_openscript` for ad-hoc.++- npm: `ov_npm install <pkg>` (console/GUI) -> npm child process ->++ watch-triggered reload. Works in the Node backend; embedded backend picks++ packages up via `core/module.js` on next reload.++++## Runtime control server (engine/openvibe-js-runtime/ov-runtime.js)++++HTTP on 127.0.0.1:41997 (`server` realm; 41996 for `client` realm):++- `GET /logs` — SSE stream of runtime + game log lines++- `POST /eval` — `js_run` equivalent (body = code), returns result JSON++- `POST /exec` — run a registered concommand / forward engine command++- `POST /npm` — npm install/update into `js/`++- `GET /state` — realm, mode, players, gamemode, hooks, entities++This is what the GUI console talks to from both hosts.++++## Unified GUI (client/)++++One SPA (HTML/JS/CSS, no framework) served identically to Electron++(`serve-client-ui.mjs` :5173), the backend (`/client/` :3000) and the++in-game CEF panel (`ov_menu_url`). Routes: `portal, servers, leaderboard,++inventory, shop, settings, options, console, hud`.++++- Console route: SSE log view + command input with history; JS eval++ (server/client realm select) via runtime control server; engine commands++ via `openvibe://cmd?c=` (in-game, allowlisted in C++) or runtime `/exec`.++- Options route: game/video/audio convars (allowlisted set via++ `openvibe://convar?name=&value=`), UI prefs (localStorage), account++ settings (backend). Gamemode `manifest.json settings[]` render here too.++- HUD route (`?hud=1` overlay): round state, teams, timers, health — driven++ by `OV_HudState` net messages -> client runtime -> `ov_menu_js` push.++- Launcher and in-game share route state via localStorage +++ BroadcastChannel (existing openvibe-ui-sync contract, kept).++++## C++ additions (sdk/openvibe/)++++1. `shared/ov_js_entity.cpp` — entity natives + JS class (create, spawn,++ remove, class/model/pos/angles/health/color/material/keyvalue/fire,++ find queries, physics init/wake). Registered next to the player class.++2. `openvibe_js_server.cpp` — `js_run`, `js_openscript`, `ov_npm`++ ConCommands; Node-mode event forwarding for PlayerSpawn/Death/++ InitialSpawn/Disconnected/Round* (gap fix).++3. `openvibe_js_client.cpp` — client event forwarding (LevelInit, Think++ ticks) to client runtime; `js_run_cl`, `js_openscript_cl` gated by++ replicated `sv_allowcsjs`.++4. `vgui_openvibe_menu.cpp` — bridge actions `cmd`, `convar`, `net`;++ route commands `ov_menu_console`, `ov_menu_options`; console spew tap ->++ `window.OV.onConsoleLine`.++5. `apply-openvibe-sdk.sh` — new files + VPC entries + usermessage++ `OVNetChunk`.++++## Testing++++Node harnesses (zero C++): `tools/smoke-js-core-node.mjs`,++`tools/smoke-js-gamemodes.mjs` (all modes), plus new++`tools/test-gmodjs.mjs` covering hooks (object ids, short-circuit), net++(loopback pair of realms, rate limits, chunking), entities (SENT lifecycle,++NW sync), file sync manifest, include/AddCSJSFile, concommand, hotload,++gamemode loader. GUI: `tools/smoke-ui.mjs` (serves + asserts routes).++SRCDS smoke unchanged.@@@ -1,214 +1,183 @@--# OpenVibe JavaScript Scripting++# OpenVibe JavaScript Scripting (GModJS)--OpenVibe embeds QuickJS in the Source server DLL and exposes a small--GMod-style hook/gamemode API.++OpenVibe embeds a GMod-shaped JavaScript platform in Source. The same++`game/openvibe.games/js/` framework runs under two backends:--## File Layout++- **embedded** — QuickJS compiled into the server DLL (`ov_js_backend embedded`)++- **node** — `engine/openvibe-js-runtime/ov-runtime.js` over TCP++ (`ov_js_backend node`; server realm 41999, client realm 41998) with full++ npm (including native modules), fs.watch hot-reload, and the runtime++ control server for the GUI console.--```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:++See `docs/GMODJS_ARCHITECTURE.md` for the design blueprint.--```text--js/gamemodes/<ov_mode>/server.js--```++## Realms--## Hook API++GMod States parity: `SERVER` / `CLIENT` globals (`js/core/realm.js`). The++server realm is the embedded QuickJS or the server Node runtime; the client++realm is the client Node runtime; the menu realm is the HTML GUI.--Use the GMod-shaped API:++## File layout & loading order--```js--hook.Add("PlayerSpawn", "MyAddon.SpawnMessage", function (ply) {-- ply.chat("Spawn hook from JS.");--});++```text++js/++ core/ hook, gamemode, util, net, entity, ents, player, team,++ file, concommand, command, timer, module, addon, loader++ autorun/ both realms (auto-networked)++ autorun/server/ server only++ autorun/client/ client only (auto-networked)++ entities/ scripted entities (single-file or folder form)++ gamemodes/<mode>/{manifest.json, shared.js, server.js, client.js}++ node_modules/ npm packages (require()-able)++ vendor/ local packages referenced from package.json (file:)++addons/<name>/{addon.json, shared.js, server.js, client.js}++```--hook.Remove("PlayerSpawn", "MyAddon.SpawnMessage");++Per realm (GMod Lua_Loading_Order): core → autorun (alphabetical; shared then++realm dir) → gamemode chain (base first via `manifest.json "base"`) →++scripted entities → addons → `Initialize`.--const result = hook.Run("SomeHook", arg1, arg2);--```++## Hook library--Lowercase aliases also exist:++GMod semantics (`js/core/hook.js`):```js--hook.add("PlayerSpawn", "id", fn);--hook.remove("PlayerSpawn", "id");--hook.run("PlayerSpawn", ply);++hook.Add("PlayerSpawn", "MyAddon.Spawn", function (ply) { ... });++hook.Add("Think", someEntity, function (self) { ... }); // object id: auto-removed when invalid, self prepended++hook.Remove("PlayerSpawn", "MyAddon.Spawn");++const result = hook.Run("SomeHook", a, b); // first non-undefined return wins (false counts)```--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.++Registered hooks run before the `GAMEMODE` method and can override it.--## Gamemode API----`base/server.js` defines the base `GM`. Active modes inherit from it:++## Gamemodes```js--(function () {-- const GM = {-- mode: "deathrun",-- name: "OpenVibe Deathrun",---- Initialize() {-- OV.log("Deathrun initialized.");-- },---- PlayerInitialSpawn(ply) {-- ply.chat("Welcome to Deathrun.");-- }-- };---- gamemode.set(GM);--})();++const GM = { mode: "mymode", name: "My Mode",++ CreateTeams() { team.SetUp(2, "Red", Color(235,75,60)); },++ Initialize() {}, PlayerSpawn(ply) { hook.Run("PlayerLoadout", ply); } };++gamemode.set(GM); // registers + derives from base + publishes GAMEMODE```--Globals:++`gamemode.Register`, `DeriveGamemode`, `baseclass.Get("gamemode_<name>")`++exist for GMod parity. `manifest.json` is the `<name>.txt` equivalent++(`base`, `settings[]` rendered in the Options GUI).--```js--GM--GAMEMODE--gamemode.get()--gamemode.getBase()--gamemode.call("HookName", ...args)--```----## Events Exposed Today++The base gamemode provides the round state machine++(`idle → countdown → active → ended`), `RoundStart`/`RoundEnd` hooks, the++`team` library, and HUD state replication (`OV_HudState` net message every++3s + on transitions → client realm → HTML HUD overlay).--The native server bridge currently fires:++## net library--```text--Initialize--MapInitialize(mapName)--Think--PlayerInitialSpawn(ply)--PlayerSpawn(ply)--PlayerDeath(victim, attacker)--PlayerDisconnected(ply)--PlayerSay(ply, text)--ConsoleCommand(text)--Shutdown--```++Full GMod surface (`js/core/net.js`): `net.Start(name, unreliable)` (name must++be pooled server-side with `util.AddNetworkString` first), writers/readers for++`Bit/Bool/Int/UInt/UInt64/Float/Double/String/Data/Vector/Normal/Angle/Color/++Entity/Player/Table/Type`, `Send/Broadcast/SendOmit/SendToServer/Abort`,++`BytesWritten/BytesLeft`, `net.Receive(name, fn(len, ply))` (one handler per++name, case-insensitive; `ply` only on the server — the authoritative sender).--## Commands----Chat commands are routed through the `PlayerSay` hook.++Faithful limits: 64KB payload cap, read-past-end returns type defaults, and++per-player rate limiting: `net.SetRateLimit("MyMsg", 10)` (default 30/s).++Transport chunks payloads over the 255-byte `OVNet` usermessage automatically.++`SendPVS/SendPAS` currently broadcast (no native PVS data yet).```js--command.Add("where", "Show mode and map", function ({ ply, reply }) {-- reply(ply, `Mode=${OV.getMode()} map=${OV.getMapName()}`);-- return false;++// server++util.AddNetworkString("MyMsg");++net.Receive("MyMsg", function (len, ply) {++ const value = net.ReadUInt(8); // validate: never trust the client++ if (value > 100) return;});++// client++net.Start("MyMsg"); net.WriteUInt(42, 8); net.SendToServer();```--Players type:++## Entities--```text--!where--```++`js/core/entity.js` + `ents.js`: `Entity` wrapper with `IsValid/EntIndex/++GetPos/SetPos/GetAngles/SetModel/Health/SetHealth/TakeDamage/PhysicsInit/++GetPhysicsObject/SetParent/SetOwner/SetColor/SetKeyValue/Fire/Remove/++CallOnRemove/...`, the `NULL` singleton, `SetNW*/GetNW*` networked vars, and++DTVar-lite `NetworkVar` declared in `SetupDataTables` — server writes++replicate to client shells automatically (`__ovnw`/`__ovdt`).--Returning `false` blocks normal chat, matching the current C++ bridge behavior.++`ents.Create/FindByClass("wildcard*")/FindInSphere/GetAll/Iterator/++GetByIndex`, client `ents.CreateClientside`. With the native entity bridge++compiled in, `ents.Create` drives real engine entities (`OV.entCreate` /++`OV.entCall`); otherwise entities are logical (harness/tests/round logic).--Console commands can be sent from SRCDS:++Scripted entities (GMod SENT layout, `js/entities/`):```text--ov_js_cmd where++entities/ov_bouncy_crate/shared.js ENT.Type/Base/PrintName/SetupDataTables++entities/ov_bouncy_crate/init.js server: AddCSJSFile + include(shared) + Initialize/Think/Use/OnTakeDamage/OnRemove++entities/ov_bouncy_crate/cl_init.js client: include(shared)```--## Timers++`scripted_ents.Register(ENT, class)` hot-patches live instances on++re-register (`OnReloaded` fires). `Think` honors `self.NextThink(CurTime()+t)`.--Timers are advanced from the `Think` hook:++## Players--```js--timer.simple(3, function () {-- OV.broadcast("Three seconds passed.");--});++`Player` extends `Entity`: `Nick/SteamID/UserID/Team/SetTeam/Alive/Kill/++Freeze/ChatPrint/ConCommand/Frags/...` plus the legacy lowercase API++(`ply.chat`, `ply.setTeam`, ...). `player.GetAll/GetByUserID/GetBySteamID/++Iterator`; `LocalPlayer()` on the client. Native handles from either backend++are wrapped automatically in hook arguments.--timer.create("round_tick", 1, 0, function (count) {-- OV.log(`Round tick ${count}`);--});--```++## AddCSJSFile / include / client script sync--`reps=0` means repeat forever.++`AddCSJSFile(path?)` (alias `AddCSLuaFile`) marks a `js/`-relative file for++client download; `autorun/`, `autorun/client/`, gamemode `shared.js`/++`client.js` + `manifest.json`, and `entities/` are auto-networked.++`include(path)` executes a file in the caller's realm (js/-rooted with++caller-dir fallback). On join the server net-sends a `{path, hash, size}`++manifest; the client requests missing files (rate-limited, allowlisted to the++manifest) and caches them under `js/ov_downloads/`, then fires++`OVFilesSynced`.--## Native Bridge++## Console commands & script execution--Available server APIs:++`concommand.Add(name, fn(ply, cmd, args, argStr))` per realm;++`RunConsoleCommand(name, ...args)` with a blocklist (can never trigger++`js_run_cl` etc.). Chat commands stay on `command.Add` (`!cmd`).--```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)--```++Engine console (GMod `lua_*` equivalents):--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")++```text++js_run <code> server realm eval++js_openscript <path> run js/<path> on the server (re-runs on repeat)++js_run_cl <code> client realm eval (gated by sv_allowcsjs)++js_openscript_cl <path> run js/<path> on the client (gated by sv_allowcsjs)++ov_npm install <pkg> npm into js/ + hot-reload (node backend)++ov_js_reload / ov_js_status / ov_js_fire / ov_js_cmd (existing)```--Server and player command execution is allowlisted to `ov_*` commands.----## Reloading++## Hotloading--In SRCDS:++- node backend: `fs.watch` on `js/` + `addons/` → debounced full reload →++ `Initialize` + `OnReloaded`; scripted entity re-register hot-patches live++ instances; `require.reload(id)` for targeted reloads.++- embedded backend: `ov_js_reload` (full), `js_openscript` (ad-hoc).++- npm: `ov_npm install <pkg>` (console), `POST /npm` (GUI console), or edit++ `js/package.json` — the watcher reloads on node_modules changes. Local++ packages live in `js/vendor/` via `file:` deps.--```text--ov_js_status--ov_js_reload--ov_js_fire Initialize--ov_js_cmd smoke--```++## Runtime control server (GUI console backend)--## Next Native Hooks To Add----Good next bridge events:++Each Node runtime serves HTTP on 127.0.0.1 (server realm 41997, client 41996):```text--EntityTakeDamage(entity, damageInfo)--PlayerUse(ply, entity)--PlayerCanPickupWeapon(ply, weapon)--PlayerLoadout(ply)++GET /health GET /state GET /logs (SSE)++POST /eval {code} POST /exec {command} POST /npm {args} POST /openscript {path}```--Already added via `ov_round_start` / `ov_round_end` ConCommands and direct C++ bridge calls:++## Testing (no C++ build needed)--```text--RoundStart(roundNumber)--RoundEnd(roundNumber, reason)++```bash++node tools/test-gmodjs.mjs # 131-check framework suite (paired realms)++node tools/smoke-js-core-node.mjs # legacy core smoke++node tools/smoke-js-gamemodes.mjs # all five modes lifecycle++node tools/smoke-runtime-ipc.mjs # real TCP IPC flow across both runtimes```@@@ -30,13 +30,26 @@ function arg(name, def) {const REALM = arg('realm', 'server'); // "server" | "client"const MODE = arg('mode', 'sandbox');const PORT = Number(arg('port', REALM === 'client' ? 41998 : 41999));++const CTRL_PORT = Number(arg('ctrl-port', REALM === 'client' ? 41996 : 41997));const ROOT = path.resolve(arg('root', path.join(__dirname, '..', '..')));const MOD = path.join(ROOT, 'game', 'openvibe.games');const JS = path.join(MOD, 'js');const IS_SERVER = REALM === 'server';--function log(...a) { console.log(`[ov-runtime/${REALM}]`, ...a); }--function warn(...a) { console.warn(`[ov-runtime/${REALM}] WARN`, ...a); }++// ---- log ring buffer + SSE fanout (consumed by the GUI console) ----++const LOG_RING_MAX = 500;++const logRing = [];++const sseClients = new Set();++let logSeq = 0;++function pushLog(level, text) {++ const entry = { seq: ++logSeq, t: Date.now(), level, text: String(text), realm: REALM };++ logRing.push(entry);++ if (logRing.length > LOG_RING_MAX) logRing.shift();++ const data = `data: ${JSON.stringify(entry)}\n\n`;++ for (const res of sseClients) { try { res.write(data); } catch { sseClients.delete(res); } }++}++function log(...a) { console.log(`[ov-runtime/${REALM}]`, ...a); pushLog('info', a.join(' ')); }++function warn(...a) { console.warn(`[ov-runtime/${REALM}] WARN`, ...a); pushLog('warn', a.join(' ')); }// ---- IPC: one connected game client at a time ----let sock = null;@@@ -62,29 +75,47 @@ function buildOV() {return {log: (m) => { log('[js]', m); },warn: (m) => { warn('[js]', m); },-- error: (m) => { console.error(`[ov-runtime/${REALM}] [js ERROR]`, m); },++ error: (m) => { console.error(`[ov-runtime/${REALM}] [js ERROR]`, m); pushLog('error', String(m)); },isServer: () => IS_SERVER,getMode: () => MODE,getMapName: () => state.map,time: () => Date.now() / 1000,readFile: (p) => { try { return fs.readFileSync(modRel(p), 'utf8'); } catch { return null; } },-- fileExists: (p) => { try { return fs.statSync(modRel(p)).isFile(); } catch { return false; } },++ fileExists: (p) => { try { fs.statSync(modRel(p)); return true; } catch { return false; } },listDir: (d /*, wildcard */) => { try { return fs.readdirSync(modRel(d)); } catch { return []; } },++ // Write access is jailed to the client download cache + data dir.++ writeFile: (p, content) => {++ const norm = path.normalize(String(p)).replace(/^([/\\]|\.\.)+/, '');++ if (!norm.startsWith(path.join('js', 'ov_downloads')) && !norm.startsWith('data')) {++ warn('writeFile blocked outside js/ov_downloads|data:', norm);++ return false;++ }++ try {++ const full = modRel(norm);++ fs.mkdirSync(path.dirname(full), { recursive: true });++ fs.writeFileSync(full, String(content));++ return true;++ } catch (e) { warn('writeFile', e.message); return false; }++ },freeMem: () => {},players: () => Object.values(state.players).map(wrapPlayer),playerByUserId: (id) => state.players[id] ? wrapPlayer(state.players[id]) : null,++ localPlayer: () => (!IS_SERVER && state.localUserId != null && state.players[state.localUserId])++ ? wrapPlayer(state.players[state.localUserId]) : null,broadcast: (msg) => sendToGame({ t: 'chat', msg: String(msg) }),serverCommand: (cmd) => sendToGame({ t: 'concmd', cmd: String(cmd) }),netEmit: (idsCsv, name, payloadB64) => sendToGame({ t: 'net', ids: String(idsCsv), name: String(name), payload: String(payloadB64) }),netSendToServer: (name, payloadB64) => sendToGame({ t: 'net', toServer: true, name: String(name), payload: String(payloadB64) }),fireHook: (name, ...args) => { try { return ctx.hook.Run(name, ...args); } catch (e) { warn('fireHook', e.message); } },++ // Push JS into the in-game HTML panel (client realm): runs ov_menu_js.++ menuJS: (script) => sendToGame({ t: 'concmd', cmd: 'ov_menu_js ' + String(script) }),reward: () => {},endMatch: () => {},};}// Live game state mirrored from IPC events.--const state = { map: '', players: {} /* userId -> {userId,name,steamId,...} */ };++const state = { map: '', players: {} /* userId -> {userId,name,steamId,...} */, localUserId: null };// Return a wrapped player, registering a minimal record if we haven't seen a// full connect event yet (so chat replies / net handlers always have a target).function ensurePlayer(userId, name) {@@@ -94,14 +125,22 @@ function ensurePlayer(userId, name) {return wrapPlayer(state.players[userId]);}function wrapPlayer(p) {-- return {++ const native = {userId: () => p.userId, entIndex: () => p.entIndex || p.userId,steamId: () => p.steamId || '', name: () => p.name || ('Player' + p.userId),-- health: () => p.health || 100, setHealth: (v) => sendToGame({ t: 'player', userId: p.userId, setHealth: v }),++ health: () => p.health || 100, setHealth: (v) => { p.health = v; sendToGame({ t: 'player', userId: p.userId, setHealth: v }); },team: () => p.team || 0, setTeam: (v) => { p.team = v; sendToGame({ t: 'player', userId: p.userId, setTeam: v }); },chat: (m) => sendToGame({ t: 'chat', userId: p.userId, msg: String(m) }),runCommand: (c) => sendToGame({ t: 'runcmd', userId: p.userId, cmd: String(c) }),++ getPos: () => p.pos || null,++ kill: () => sendToGame({ t: 'player', userId: p.userId, kill: true }),};++ // Upgrade to the framework Player class when loaded (gamemodes then get the++ // full GMod surface: Nick/SetTeam/ChatPrint/NW vars/...).++ if (ctx && ctx.Player && typeof ctx.Player.fromNative === 'function') {++ try { return ctx.Player.fromNative(native); } catch { /* fall through */ }++ }++ return native;}// Build the Node require npm + the addon loader use. Resolved from the mod's js@@@ -130,18 +169,20 @@ function buildRequire() {function loadFramework() {// Clear framework globals so re-eval redefines them (core files guard against// re-init, which would otherwise no-op a hot-reload).-- for (const g of ['hook', 'gamemode', 'GM', 'GAMEMODE', 'net', 'util', 'command', 'concommand', 'timer', 'Addon', 'module', 'OVSandbox']) {++ for (const g of ['hook', 'gamemode', 'GM', 'GAMEMODE', 'net', 'util', 'command', 'concommand',++ 'timer', 'Addon', 'module', 'OVSandbox', 'Entity', 'Player', 'player', 'ents',++ 'scripted_ents', 'file', 'OVLoader', 'include', 'AddCSJSFile', 'AddCSLuaFile',++ 'SERVER', 'CLIENT', 'MENU', 'NULL', 'LocalPlayer', 'RunConsoleCommand',++ 'DeriveGamemode', 'baseclass', 'IsValid', 'CurTime', 'ENT']) {try { delete global[g]; } catch {}}global.OV = buildOV();global.require = buildRequire();-- const order = [-- 'core/hook.js', 'core/gamemode.js', 'bridge.js', 'core/command.js', 'core/timer.js',-- 'gamemodes/base/server.js',-- `gamemodes/${MODE}/shared.js`,-- `gamemodes/${MODE}/${IS_SERVER ? 'server' : 'client'}.js`,-- ];++ // Core files in the same order the embedded C++ loader uses; bridge.js++ // bootstraps the remaining core libraries (realm/util/net/entity/ents/++ // player/file/concommand/addon/loader).++ const order = ['core/hook.js', 'core/gamemode.js', 'bridge.js', 'core/command.js', 'core/timer.js'];for (const f of order) {const full = path.join(JS, f);if (!fs.existsSync(full)) continue;@@@ -149,6 +190,21 @@ function loadFramework() {catch (e) { warn(`load ${f}: ${e.message}`); }}ctx = global;++++ // GMod loading order: autorun -> gamemode chain -> entities -> addons.++ if (ctx.OVLoader && typeof ctx.OVLoader.loadAll === 'function') {++ try { ctx.OVLoader.loadAll({ mode: MODE }); }++ catch (e) { warn('OVLoader.loadAll:', e.message); }++ } else {++ // Fallback for a stripped js/ tree (old layout).++ for (const f of ['gamemodes/base/server.js', `gamemodes/${MODE}/shared.js`,++ `gamemodes/${MODE}/${IS_SERVER ? 'server' : 'client'}.js`]) {++ const full = path.join(JS, f);++ if (!fs.existsSync(full)) continue;++ try { vm.runInThisContext(fs.readFileSync(full, 'utf8'), { filename: f }); }++ catch (e) { warn(`load ${f}: ${e.message}`); }++ }++ }log(`framework loaded (mode=${MODE}, realm=${REALM})`);}@@@ -195,11 +251,18 @@ function onGameMessage(msg) {break;}case 'player_connect':-- state.players[msg.userId] = { userId: msg.userId, name: msg.name, steamId: msg.steamId };++ state.players[msg.userId] = { userId: msg.userId, name: msg.name, steamId: msg.steamId, entIndex: msg.entIndex };++ if (msg.local) state.localUserId = msg.userId;++ fire('PlayerConnect', msg.name, msg.ip || '');break;case 'player_disconnect':++ fire('PlayerDisconnected', state.players[msg.userId] ? wrapPlayer(state.players[msg.userId]) : null);delete state.players[msg.userId];break;++ case 'local_player':++ state.localUserId = msg.userId;++ if (!state.players[msg.userId]) state.players[msg.userId] = { userId: msg.userId, name: msg.name, steamId: msg.steamId, entIndex: msg.entIndex };++ break;case 'net': {// client->server (or server->client) net message -> OVNetReceive hookconst ply = ensurePlayer(msg.userId, msg.name_) ;@@@ -215,11 +278,159 @@ function onGameMessage(msg) {case 'think':if (ctx && ctx.hook) { try { ctx.hook.Run('Think'); } catch {} }break;++ case 'eval':++ // js_run / js_run_cl from the game console.++ if (ctx && ctx.OVLoader && typeof ctx.OVLoader.runString === 'function') {++ try { ctx.OVLoader.runString(msg.code, 'console'); } catch (e) { warn('eval:', e.message); }++ }++ break;++ case 'openscript':++ // js_openscript / js_openscript_cl from the game console.++ if (ctx && ctx.OVLoader && typeof ctx.OVLoader.openScript === 'function') {++ try { ctx.OVLoader.openScript(msg.path); } catch (e) { warn('openscript:', e.message); }++ }++ break;++ case 'npm':++ // ov_npm from the game console (server realm).++ runNpm(msg.args || [], () => {});++ break;default:break;}}++// ---- runtime control HTTP server (GUI console / options / tooling) ----++// GET /logs SSE stream of runtime log lines (plus a small backlog)++// GET /state realm/mode/map/players/gamemode/hooks summary++// POST /eval { code } js_run equivalent in this realm++// POST /exec { command } concommand dispatch or engine forward++// POST /npm { args: ["install", "pkg"] } npm in the js/ tree + hot reload++// GET /health++const http = require('http');++const { execFile: cpExecFile } = require('child_process');++++function readBody(req) {++ return new Promise((resolve) => {++ let buf = '';++ req.on('data', (c) => { buf += c; if (buf.length > 1 << 20) req.destroy(); });++ req.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { resolve({}); } });++ });++}++function json(res, code, obj) {++ const body = JSON.stringify(obj);++ res.writeHead(code, {++ 'Content-Type': 'application/json',++ 'Access-Control-Allow-Origin': '*',++ 'Access-Control-Allow-Headers': 'Content-Type',++ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',++ });++ res.end(body);++}++++let npmBusy = false;++function runNpm(args, cb) {++ if (npmBusy) return cb({ ok: false, error: 'npm already running' });++ const allowed = ['install', 'i', 'update', 'uninstall', 'remove', 'ls', 'list'];++ if (!args.length || !allowed.includes(args[0])) return cb({ ok: false, error: `npm subcommand not allowed: ${args[0] || '(none)'}` });++ const safe = args.filter((a) => /^[-@a-zA-Z0-9._/^~:]+$/.test(String(a)));++ npmBusy = true;++ log('npm', safe.join(' '));++ cpExecFile('npm', [...safe, '--no-fund', '--no-audit'], { cwd: JS, timeout: 120000 }, (err, stdout, stderr) => {++ npmBusy = false;++ const out = String(stdout || '') + String(stderr || '');++ out.split('\n').filter(Boolean).slice(0, 40).forEach((l) => pushLog('npm', l));++ if (err) return cb({ ok: false, error: err.message, output: out });++ // fs.watch on js/ picks up node_modules changes and hot-reloads; force one++ // anyway so `npm ls`-style no-op writes still refresh requires.++ loadFramework();++ fire('Initialize');++ fire('OnReloaded');++ cb({ ok: true, output: out });++ });++}++++const ctrl = http.createServer(async (req, res) => {++ const url = new URL(req.url, 'http://127.0.0.1');++ if (req.method === 'OPTIONS') return json(res, 200, { ok: true });++ if (req.method === 'GET' && url.pathname === '/health') {++ return json(res, 200, { ok: true, service: 'ov-runtime', realm: REALM, mode: MODE });++ }++ if (req.method === 'GET' && url.pathname === '/logs') {++ res.writeHead(200, {++ 'Content-Type': 'text/event-stream',++ 'Cache-Control': 'no-cache',++ 'Connection': 'keep-alive',++ 'Access-Control-Allow-Origin': '*',++ });++ for (const entry of logRing.slice(-100)) res.write(`data: ${JSON.stringify(entry)}\n\n`);++ sseClients.add(res);++ req.on('close', () => sseClients.delete(res));++ return;++ }++ if (req.method === 'GET' && url.pathname === '/state') {++ let gm = null, hooks = 0, entities = 0;++ try {++ gm = ctx && ctx.GAMEMODE ? { mode: ctx.GAMEMODE.mode, name: ctx.GAMEMODE.name } : null;++ if (ctx && ctx.hook) hooks = Object.keys(ctx.hook.GetTable()).length;++ if (ctx && ctx.ents) entities = ctx.ents.GetCount();++ } catch {}++ return json(res, 200, {++ ok: true, realm: REALM, mode: MODE, map: state.map, gameConnected: !!(sock && !sock.destroyed),++ players: Object.values(state.players).map((p) => ({ userId: p.userId, name: p.name, team: p.team || 0 })),++ gamemode: gm, hookEvents: hooks, entities,++ });++ }++ if (req.method === 'POST' && url.pathname === '/eval') {++ const body = await readBody(req);++ if (typeof body.code !== 'string') return json(res, 400, { ok: false, error: 'missing code' });++ let out;++ if (ctx && ctx.OVLoader && ctx.OVLoader.runString) out = ctx.OVLoader.runString(body.code, 'console-eval');++ else { try { out = { ok: true, result: vm.runInThisContext(body.code, { filename: 'console-eval' }) }; } catch (e) { out = { ok: false, error: e.message }; } }++ let rendered = null;++ try { rendered = out.result === undefined ? null : JSON.parse(JSON.stringify(out.result)); }++ catch { rendered = String(out.result); }++ pushLog('eval', `> ${body.code}`);++ if (out.ok) pushLog('eval', `= ${rendered === null ? 'undefined' : JSON.stringify(rendered)}`);++ else pushLog('error', out.error);++ return json(res, 200, { ok: out.ok, result: rendered, error: out.error || null });++ }++ if (req.method === 'POST' && url.pathname === '/exec') {++ const body = await readBody(req);++ const line = String(body.command || '').trim();++ if (!line) return json(res, 400, { ok: false, error: 'missing command' });++ pushLog('cmd', `] ${line}`);++ let handled = false;++ try {++ if (ctx && ctx.concommand && ctx.concommand.Dispatch) handled = !!ctx.concommand.Dispatch(null, line);++ if (!handled && ctx && ctx.command && ctx.command.dispatchConsole) {++ const r = ctx.command.dispatchConsole(line);++ handled = r !== undefined;++ }++ } catch (e) { return json(res, 200, { ok: false, error: e.message }); }++ if (!handled) {++ // Forward to the engine (the DLL applies its ov_*/say allowlist).++ sendToGame({ t: 'concmd', cmd: line });++ handled = 'forwarded';++ }++ return json(res, 200, { ok: true, handled });++ }++ if (req.method === 'POST' && url.pathname === '/npm') {++ const body = await readBody(req);++ const args = Array.isArray(body.args) ? body.args.map(String) : [];++ return runNpm(args, (result) => json(res, result.ok ? 200 : 400, result));++ }++ if (req.method === 'POST' && url.pathname === '/openscript') {++ const body = await readBody(req);++ const p = String(body.path || '');++ if (!p) return json(res, 400, { ok: false, error: 'missing path' });++ const ok = !!(ctx && ctx.OVLoader && ctx.OVLoader.openScript(p));++ return json(res, 200, { ok });++ }++ return json(res, 404, { ok: false, error: 'not found' });++});++ctrl.on('error', (e) => warn('control server error:', e.message));++ctrl.listen(CTRL_PORT, '127.0.0.1', () => log(`control server on 127.0.0.1:${CTRL_PORT}`));++// ---- TCP server ----const server = net.createServer((s) => {if (sock && !sock.destroyed) { warn('replacing existing game connection'); try { sock.destroy(); } catch {} }@@@ -1,2 +1,3 @@// OpenVibe autoexec. Safe in both native and Proton modes.exec openvibe_proton_client.cfg++exec openvibe_client_default.cfgnnew file mode 100644@@@ -0,0 +1,12 @@++// OpenVibe client defaults — the HTML GUI console replaces the stock Source++// console. Executed from autoexec.cfg.++//++// con_enable 0 keeps the engine's VGUI console from opening; the console key++// toggles the OpenVibe console route instead (ov_console, from client.dll).++// Use "con_enable 1; toggleconsole" from the OpenVibe console if the stock++// console is ever needed for emergencies.++con_enable 0++bind "`" "ov_console"++bind "F10" "ov_console"++bind "F1" "ov_menu_main"++echo "OpenVibe: stock console disabled; ` and F10 toggle the OpenVibe console"nnew file mode 100644@@@ -0,0 +1,17 @@++// Example autorun script — runs in BOTH realms before the gamemode loads++// (GMod lua/autorun equivalent). Auto-networked to clients.++(function () {++ if (SERVER) {++ hook.Add("PlayerInitialSpawn", "OpenVibeMotd", function (ply) {++ if (globalThis.timer && timer.Simple) {++ timer.Simple(1, function () {++ if (ply && ply.ChatPrint) ply.ChatPrint("[MOTD] OpenVibe GModJS platform — !help for commands, F10 for console.");++ });++ }++ });++ } else {++ hook.Add("Initialize", "OpenVibeMotdClient", function () {++ OV.log("[MOTD] autorun ran on the client realm");++ });++ }++})();@@@ -27,9 +27,18 @@}}-- bootstrap("js/core/module.js"); // defines globalThis.require-- bootstrap("js/core/net.js"); // defines globalThis.net + util.AddNetworkString-- bootstrap("js/core/addon.js"); // defines globalThis.Addon++ bootstrap("js/core/realm.js"); // SERVER / CLIENT / IsValid / CurTime++ bootstrap("js/core/module.js"); // globalThis.require (CommonJS + npm)++ bootstrap("js/core/util.js"); // util.AddNetworkString pooling + helpers++ bootstrap("js/core/net.js"); // GMod net library++ bootstrap("js/core/entity.js"); // Entity class + NW/DT vars++ bootstrap("js/core/ents.js"); // ents + scripted_ents++ bootstrap("js/core/player.js"); // Player class + player library++ bootstrap("js/core/team.js"); // team library++ bootstrap("js/core/file.js"); // AddCSJSFile / include / client sync++ bootstrap("js/core/concommand.js"); // concommand + RunConsoleCommand++ bootstrap("js/core/addon.js"); // globalThis.Addon++ bootstrap("js/core/loader.js"); // OVLoader (autorun/gamemodes/entities)// NOTE: Addon.loadAll() is intentionally NOT called here. bridge.js loads// before command.js/timer.js in the C++ core order, so addons that register@@@ -55,8 +55,13 @@const entry = commands[parsed.name];if (!entry) {-- reply(ply, `Unknown command: ${parsed.name}. Try !help`);-- return false;++ // Chat (!cmd) gets feedback; console lines fall through to other++ // handlers (concommand registry, engine) instead.++ if (ply) {++ reply(ply, `Unknown command: ${parsed.name}. Try !help`);++ return false;++ }++ return undefined;}const result = entry.fn({ ply, name: parsed.name, args: parsed.args, raw: parsed.raw, reply });@@@ -84,25 +89,21 @@}};-- 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();-- }-- };++ // NOTE: the GMod-style concommand library lives in core/concommand.js++ // (loaded by bridge.js before this file); command.js only owns chat (!cmd)++ // commands. A minimal shim is kept for hosts without the new core files.++ if (!globalThis.concommand) {++ globalThis.concommand = {++ Add(name, fn, help) {++ return command.add(name, help || "", function ({ ply, args, raw, reply }) {++ return fn(ply || null, name, args, raw);++ });++ },++ Remove(name) {++ return command.remove(name);++ }++ };++ }hook.add("PlayerSay", "OpenVibeCommandRegistry", function (ply, text) {const msg = String(text || "").trim();nnew file mode 100644@@@ -0,0 +1,81 @@++// OpenVibe concommand library — GMod concommand semantics.++// https://wiki.facepunch.com/gmod/concommand++//++// Realm-local registry. Server commands are dispatched from the++// "ConsoleCommand" hook (fed by the ov_js_cmd ConCommand and the runtime++// control server); client commands from the client runtime's /exec channel.++// RunConsoleCommand forwards to the engine through the bridge, with a++// blocklist so scripts can never trigger the client-realm eval commands.++(function () {++ if (globalThis.concommand && globalThis.concommand.__openvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++++ var commands = Object.create(null); // lowercase name -> {fn, help}++++ // Commands Lua/JS may never trigger programmatically (GMod Blocked_ConCommands++ // spirit): client-realm eval + engine-critical toggles.++ var BLOCKED = { js_run_cl: 1, js_openscript_cl: 1, ov_js_run_cl: 1, exec: 1, connect: 1, retry: 1, bind: 1, unbindall: 1, alias: 1, sv_cheats: 1 };++++ function parseArgs(str) {++ var out = [];++ var rx = /"([^"]*)"|(\S+)/g, m;++ while ((m = rx.exec(str))) out.push(m[1] !== undefined ? m[1] : m[2]);++ return out;++ }++++ var concommand = {++ __openvibe: true,++ Add: function (name, fn, autoComplete, helpText) {++ if (typeof fn !== "function") throw new Error("concommand.Add requires a callback");++ commands[String(name).toLowerCase()] = { fn: fn, help: helpText || "" };++ },++ Remove: function (name) { delete commands[String(name).toLowerCase()]; },++ GetTable: function () {++ var out = {};++ for (var k in commands) out[k] = commands[k].fn;++ return out;++ },++ Run: function (ply, cmd, args, argStr) {++ var entry = commands[String(cmd).toLowerCase()];++ if (!entry) return false;++ try { entry.fn(ply || null, String(cmd), args || [], argStr || ""); }++ catch (e) { OV && OV.error && OV.error("concommand '" + cmd + "': " + (e && e.stack ? e.stack : e)); }++ return true;++ },++ // Dispatch a raw console line ("name arg1 arg2 ...").++ Dispatch: function (ply, line) {++ line = String(line || "").trim();++ if (!line) return false;++ var space = line.indexOf(" ");++ var name = space < 0 ? line : line.slice(0, space);++ var argStr = space < 0 ? "" : line.slice(space + 1);++ return concommand.Run(ply, name, parseArgs(argStr), argStr);++ }++ };++++ globalThis.concommand = concommand;++++ globalThis.RunConsoleCommand = function (name) {++ name = String(name).toLowerCase();++ if (BLOCKED[name]) {++ OV && OV.warn && OV.warn("RunConsoleCommand: '" + name + "' is blocked");++ return;++ }++ var args = Array.prototype.slice.call(arguments, 1).map(String);++ var line = name + (args.length ? " " + args.map(function (a) { return /\s/.test(a) ? '"' + a + '"' : a; }).join(" ") : "");++ if (concommand.Dispatch(null, line)) return; // JS-registered command++ if (OV && OV.serverCommand) OV.serverCommand(line); // engine (ov_*/say allowlist applies)++ };++++ // Server realm: wire into the existing ConsoleCommand hook (ov_js_cmd text).++ if (globalThis.hook && typeof hook.Add === "function") {++ hook.Add("ConsoleCommand", "OpenVibeConCommandDispatch", function (text) {++ if (concommand.Dispatch(null, text)) return false; // handled++ return undefined;++ });++ }++++ if (OV && OV.log) OV.log("concommand library ready");++})();nnew file mode 100644@@@ -0,0 +1,374 @@++// OpenVibe Entity system — GMod Entity class semantics in JS.++// https://wiki.facepunch.com/gmod/Entity++//++// Entities are id-handled wrappers over an internal registry. When the native++// bridge exposes entity bindings (OV.entCreate/OV.entCall) the wrapper drives++// the real engine entity; otherwise it is a pure-JS logical entity with the++// same lifecycle (usable in the Node harness, tests, and round logic).++//++// Networked state:++// - SetNW*/GetNW* — keyed store, server writes replicate via "__ovnw"++// - NetworkVar — DTVar-lite slots declared in ENT:SetupDataTables,++// replicate via "__ovdt", generate Get<Name>/Set<Name>++(function () {++ if (globalThis.Entity && globalThis.Entity.__openvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++++ // ---- math globals (GMod parity) ----++ if (!globalThis.Vector) {++ globalThis.Vector = function (x, y, z) { return { x: +x || 0, y: +y || 0, z: +z || 0 }; };++ }++ if (!globalThis.Angle) {++ globalThis.Angle = function (p, y, r) { return { p: +p || 0, y: +y || 0, r: +r || 0 }; };++ }++ if (!globalThis.Color) {++ globalThis.Color = function (r, g, b, a) { return { r: r | 0, g: g | 0, b: b | 0, a: a === undefined ? 255 : a | 0 }; };++ }++ function vcopy(v) { return { x: +v.x || 0, y: +v.y || 0, z: +v.z || 0 }; }++ function acopy(a) { return { p: +a.p || 0, y: +a.y || 0, r: +a.r || 0 }; }++ function dist2(a, b) {++ var dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;++ return dx * dx + dy * dy + dz * dz;++ }++++ // ---- enums (subset used by gamemodes) ----++ globalThis.SOLID_NONE = 0; globalThis.SOLID_BBOX = 2; globalThis.SOLID_VPHYSICS = 6;++ globalThis.MOVETYPE_NONE = 0; globalThis.MOVETYPE_WALK = 2; globalThis.MOVETYPE_NOCLIP = 8;++ globalThis.MOVETYPE_VPHYSICS = 6; globalThis.MOVETYPE_FLY = 5;++ globalThis.COLLISION_GROUP_NONE = 0; globalThis.COLLISION_GROUP_DEBRIS = 1; globalThis.COLLISION_GROUP_WORLD = 3;++ globalThis.RENDERMODE_NORMAL = 0; globalThis.RENDERMODE_TRANSALPHA = 4;++ globalThis.SIMPLE_USE = 0; globalThis.CONTINUOUS_USE = 1; globalThis.ONOFF_USE = 2;++++ // ---- registry ----++ var registry = Object.create(null); // key(entIndex) -> Entity++ var pendingRemoval = [];++ var nextLogicalIndex = 16384; // pure-JS server entities sit above engine edicts++ var nextClientsideKey = -2; // clientside-only entities (EntIndex() reports -1)++++ function native() { return OV && typeof OV.entCall === "function"; }++ function ncall(idx, method, args) {++ try { return OV.entCall(idx, method, args || []); }++ catch (e) { if (OV && OV.warn) OV.warn("entCall " + method + ": " + (e && e.message)); return undefined; }++ }++++ // NULL singleton++ var NULL_ENTITY = null;++++ function Entity(key) {++ // Calling Entity(n) as a function resolves an index (GMod Global.Entity).++ if (!(this instanceof Entity)) {++ var e = registry[key | 0];++ return e && !e._r.removed ? e : NULL_ENTITY;++ }++ this.__ovEntity = true;++ this._key = key;++ this._r = {++ class: "", model: "", pos: Vector(), ang: Angle(),++ health: 0, maxHealth: 0, color: Color(255, 255, 255, 255), material: "",++ moveType: 0, solid: 0, collisionGroup: 0, renderMode: 0, useType: 0,++ name: "", keyvalues: {}, parent: null, owner: null,++ spawned: false, removed: false, noDraw: false, trigger: false,++ nw: Object.create(null), nwProxies: Object.create(null),++ dt: Object.create(null), dtNotify: Object.create(null), dtSlots: Object.create(null),++ removeCallbacks: [], deleteOnRemove: [],++ nextThink: 0, createdAt: (OV && OV.time ? OV.time() : 0),++ isNative: false, clientside: key < 0++ };++ }++ Entity.__openvibe = true;++++ var P = Entity.prototype;++++ // ---- identity / lifecycle ----++ P.IsValid = function () { return !this._r.removed && this !== NULL_ENTITY; };++ P.isValid = P.IsValid;++ P.EntIndex = function () { return this._r.clientside ? -1 : this._key; };++ P.entIndex = P.EntIndex;++ P.GetClass = function () { return this._r.class; };++ P.GetModel = function () { return this._r.model || null; };++ P.IsWorld = function () { return this._key === 0; };++ P.GetCreationTime = function () { return this._r.createdAt; };++ P.GetTable = function () { return this; };++ P.IsPlayer = function () { return false; };++ P.IsNPC = function () { return false; };++ P.IsWeapon = function () { return false; };++ P.IsScripted = function () { return !!this._ENT; };++++ P.Spawn = function () {++ if (this._r.spawned || this._r.removed) return;++ this._r.spawned = true;++ if (this._r.isNative && native()) ncall(this._key, "spawn");++ if (typeof this.Initialize === "function") {++ try { this.Initialize(); } catch (e) { OV && OV.error && OV.error("ENT:Initialize " + this._r.class + ": " + (e && e.stack || e)); }++ }++ };++ P.Activate = function () { if (this._r.isNative && native()) ncall(this._key, "activate"); };++++ P.Remove = function () {++ if (this._r.removed) return;++ if (pendingRemoval.indexOf(this) < 0) pendingRemoval.push(this);++ };++ P.IsMarkedForDeletion = function () { return pendingRemoval.indexOf(this) >= 0; };++ P.CallOnRemove = function (id, fn) { this._r.removeCallbacks.push({ id: String(id), fn: fn }); };++ P.RemoveCallOnRemove = function (id) {++ this._r.removeCallbacks = this._r.removeCallbacks.filter(function (c) { return c.id !== String(id); });++ };++ P.DeleteOnRemove = function (ent) { this._r.deleteOnRemove.push(ent); };++++ // ---- transform ----++ P.GetPos = function () {++ if (this._r.isNative && native()) { var v = ncall(this._key, "getPos"); if (v) this._r.pos = v; }++ return vcopy(this._r.pos);++ };++ P.SetPos = function (v) { this._r.pos = vcopy(v || {}); if (this._r.isNative && native()) ncall(this._key, "setPos", [this._r.pos.x, this._r.pos.y, this._r.pos.z]); };++ P.GetAngles = function () {++ if (this._r.isNative && native()) { var a = ncall(this._key, "getAngles"); if (a) this._r.ang = a; }++ return acopy(this._r.ang);++ };++ P.SetAngles = function (a) { this._r.ang = acopy(a || {}); if (this._r.isNative && native()) ncall(this._key, "setAngles", [this._r.ang.p, this._r.ang.y, this._r.ang.r]); };++ P.GetForward = function () {++ var a = this.GetAngles(), p = a.p * Math.PI / 180, y = a.y * Math.PI / 180;++ return Vector(Math.cos(p) * Math.cos(y), Math.cos(p) * Math.sin(y), -Math.sin(p));++ };++ P.GetVelocity = function () { return (this._r.isNative && native() && ncall(this._key, "getVelocity")) || Vector(); };++ P.SetVelocity = function (v) { if (this._r.isNative && native()) ncall(this._key, "setVelocity", [v.x, v.y, v.z]); };++ P.EyePos = function () { var p = this.GetPos(); return Vector(p.x, p.y, p.z + 64); };++++ // ---- appearance ----++ P.SetModel = function (m) { this._r.model = String(m); if (this._r.isNative && native()) ncall(this._key, "setModel", [this._r.model]); };++ P.SetModelScale = function (s) { if (this._r.isNative && native()) ncall(this._key, "setModelScale", [+s || 1]); };++ P.SetColor = function (c) { this._r.color = c ? Color(c.r, c.g, c.b, c.a) : Color(255, 255, 255); if (this._r.isNative && native()) ncall(this._key, "setColor", [this._r.color.r, this._r.color.g, this._r.color.b, this._r.color.a]); };++ P.GetColor = function () { return this._r.color; };++ P.SetMaterial = function (m) { this._r.material = String(m || ""); if (this._r.isNative && native()) ncall(this._key, "setMaterial", [this._r.material]); };++ P.GetMaterial = function () { return this._r.material; };++ P.SetRenderMode = function (m) { this._r.renderMode = m | 0; if (this._r.isNative && native()) ncall(this._key, "setRenderMode", [m | 0]); };++ P.SetNoDraw = function (b) { this._r.noDraw = !!b; if (this._r.isNative && native()) ncall(this._key, "setNoDraw", [!!b]); };++ P.GetNoDraw = function () { return this._r.noDraw; };++++ // ---- health / damage ----++ P.Health = function () {++ if (this._r.isNative && native()) { var h = ncall(this._key, "health"); if (h !== undefined) this._r.health = h; }++ return this._r.health;++ };++ P.SetHealth = function (v) { this._r.health = v | 0; if (this._r.isNative && native()) ncall(this._key, "setHealth", [v | 0]); };++ P.GetMaxHealth = function () { return this._r.maxHealth; };++ P.SetMaxHealth = function (v) { this._r.maxHealth = v | 0; if (this._r.isNative && native()) ncall(this._key, "setMaxHealth", [v | 0]); };++ P.Alive = function () { return this.Health() > 0; };++ P.TakeDamage = function (dmg, attacker, inflictor) {++ if (!isServer) return;++ var info = { damage: +dmg || 0, attacker: attacker || NULL_ENTITY, inflictor: inflictor || attacker || NULL_ENTITY,++ GetDamage: function () { return this.damage; }, GetAttacker: function () { return this.attacker; },++ GetInflictor: function () { return this.inflictor; }, SetDamage: function (d) { this.damage = +d || 0; },++ ScaleDamage: function (s) { this.damage *= s; } };++ var block = globalThis.hook ? hook.Run("EntityTakeDamage", this, info) : undefined;++ if (block === true) return;++ if (typeof this.OnTakeDamage === "function") {++ try { this.OnTakeDamage(info); return; } catch (e) { OV && OV.error && OV.error("OnTakeDamage: " + (e && e.message)); }++ }++ if (this._r.isNative && native()) { ncall(this._key, "takeDamage", [info.damage, attacker ? attacker.EntIndex() : 0]); return; }++ this.SetHealth(this.Health() - info.damage);++ if (this.Health() <= 0 && !this._r.clientside) this.Remove();++ };++++ // ---- physics / movement ----++ P.PhysicsInit = function (solidType) {++ if (this._r.isNative && native()) return !!ncall(this._key, "physicsInit", [solidType | 0]);++ this._r.solid = solidType | 0;++ return true;++ };++ P.PhysWake = function () { if (this._r.isNative && native()) ncall(this._key, "physWake"); };++ P.GetPhysicsObject = function () {++ var self = this;++ return {++ IsValid: function () { return self._r.solid !== 0 || (self._r.isNative && native() && !!ncall(self._key, "hasPhysics")); },++ Wake: function () { self.PhysWake(); },++ SetMass: function (m) { if (self._r.isNative && native()) ncall(self._key, "setMass", [+m || 1]); },++ EnableMotion: function (b) { if (self._r.isNative && native()) ncall(self._key, "enableMotion", [!!b]); },++ ApplyForceCenter: function (v) { if (self._r.isNative && native()) ncall(self._key, "applyForceCenter", [v.x, v.y, v.z]); }++ };++ };++ P.SetMoveType = function (m) { this._r.moveType = m | 0; if (this._r.isNative && native()) ncall(this._key, "setMoveType", [m | 0]); };++ P.GetMoveType = function () { return this._r.moveType; };++ P.SetSolid = function (s) { this._r.solid = s | 0; if (this._r.isNative && native()) ncall(this._key, "setSolid", [s | 0]); };++ P.GetSolid = function () { return this._r.solid; };++ P.SetCollisionGroup = function (g) { this._r.collisionGroup = g | 0; if (this._r.isNative && native()) ncall(this._key, "setCollisionGroup", [g | 0]); };++ P.GetCollisionGroup = function () { return this._r.collisionGroup; };++++ // ---- hierarchy ----++ P.SetParent = function (parent) { this._r.parent = parent || null; if (this._r.isNative && native()) ncall(this._key, "setParent", [parent && parent.EntIndex ? parent.EntIndex() : 0]); };++ P.GetParent = function () { return this._r.parent || NULL_ENTITY; };++ P.GetChildren = function () {++ var out = [], self = this;++ Entity._each(function (e) { if (e._r.parent === self) out.push(e); });++ return out;++ };++ P.SetOwner = function (owner) { this._r.owner = owner || null; if (this._r.isNative && native()) ncall(this._key, "setOwner", [owner && owner.EntIndex ? owner.EntIndex() : 0]); };++ P.GetOwner = function () { return this._r.owner || NULL_ENTITY; };++++ // ---- keyvalues / IO / use ----++ P.SetKeyValue = function (k, v) { this._r.keyvalues[String(k)] = String(v); if (this._r.isNative && native()) ncall(this._key, "setKeyValue", [String(k), String(v)]); if (typeof this.KeyValue === "function") { try { this.KeyValue(String(k), String(v)); } catch {} } };++ P.GetKeyValues = function () { var out = {}; for (var k in this._r.keyvalues) out[k] = this._r.keyvalues[k]; return out; };++ P.SetName = function (n) { this._r.name = String(n); };++ P.GetName = function () { return this._r.name; };++ P.Fire = function (input, param, delay) { if (this._r.isNative && native()) ncall(this._key, "fire", [String(input), String(param || ""), +delay || 0]); };++ P.SetUseType = function (t) { this._r.useType = t | 0; };++ P.SetTrigger = function (b) { this._r.trigger = !!b; };++ P.Use = function (activator, caller, useType, value) {++ if (typeof this._useHook === "function") { try { this._useHook(activator, caller, useType, value); } catch {} }++ };++ P.EmitSound = function (name) { if (this._r.isNative && native()) ncall(this._key, "emitSound", [String(name)]); };++ P.NextThink = function (t) { this._r.nextThink = +t || 0; };++ P.SetNextClientThink = P.NextThink;++++ // ---- networked vars (NW) ----++ var NW_TYPES = ["Int", "Float", "Bool", "String", "Entity", "Vector", "Angle"];++ var NW_DEFAULTS = { Int: 0, Float: 0, Bool: false, String: "", Entity: null, Vector: null, Angle: null };++ function nwSet(ent, type, key, value) {++ key = String(key);++ var old = ent._r.nw[key];++ ent._r.nw[key] = value;++ var proxy = ent._r.nwProxies[key];++ if (proxy) { try { proxy(ent, key, old, value); } catch {} }++ if (isServer && globalThis.net && net.__openvibe && OV && OV.netEmit) {++ var wire = value;++ if (type === "Entity") wire = value && value.EntIndex ? value.EntIndex() : 0;++ try {++ net.Start("__ovnw");++ net.WriteInt(ent.EntIndex());++ net.WriteString(type);++ net.WriteString(key);++ net.WriteType(wire);++ net.Broadcast();++ } catch (e) { /* net not ready during early load */ }++ }++ }++ function nwGet(ent, type, key, fallback) {++ var v = ent._r.nw[String(key)];++ if (v === undefined) return fallback !== undefined ? fallback : NW_DEFAULTS[type];++ return v;++ }++ NW_TYPES.forEach(function (type) {++ P["SetNW" + type] = function (key, value) { nwSet(this, type, key, value); };++ P["GetNW" + type] = function (key, fallback) { return nwGet(this, type, key, fallback); };++ P["SetNW2" + type] = P["SetNW" + type];++ P["GetNW2" + type] = P["GetNW" + type];++ });++ P.SetNWVarProxy = function (key, fn) { this._r.nwProxies[String(key)] = fn; };++ P.GetNWVarTable = function () { var out = {}; for (var k in this._r.nw) out[k] = this._r.nw[k]; return out; };++++ // ---- DTVars (NetworkVar) ----++ P.NetworkVar = function (type, slot, name, extended) {++ var ent = this;++ name = String(name);++ ent._r.dtSlots[name] = { type: String(type), slot: slot | 0 };++ if (ent._r.dt[name] === undefined) ent._r.dt[name] = NW_DEFAULTS[type];++ ent["Get" + name] = function () { return ent._r.dt[name]; };++ ent["Set" + name] = function (v) {++ var old = ent._r.dt[name];++ ent._r.dt[name] = v;++ var notify = ent._r.dtNotify[name];++ if (notify) { try { notify(ent, name, old, v); } catch {} }++ if (isServer && globalThis.net && net.__openvibe && OV && OV.netEmit) {++ var wire = type === "Entity" ? (v && v.EntIndex ? v.EntIndex() : 0) : v;++ try {++ net.Start("__ovdt");++ net.WriteInt(ent.EntIndex());++ net.WriteString(name);++ net.WriteType(wire);++ net.Broadcast();++ } catch (e) { /* net not ready */ }++ }++ };++ };++ P.NetworkVarNotify = function (name, fn) { this._r.dtNotify[String(name)] = fn; };++ P.GetNetworkVars = function () { var out = {}; for (var k in this._r.dt) out[k] = this._r.dt[k]; return out; };++++ // ---- internal registry api (used by ents.js / player.js) ----++ Entity._register = function (ent) { registry[ent._key] = ent; };++ Entity._unregister = function (ent) { delete registry[ent._key]; };++ Entity._each = function (fn) {++ for (var k in registry) { var e = registry[k]; if (e && !e._r.removed) fn(e); }++ };++ Entity._get = function (key) { var e = registry[key | 0]; return e && !e._r.removed ? e : NULL_ENTITY; };++ Entity._allocLogical = function () { return nextLogicalIndex++; };++ Entity._allocClientside = function () { return nextClientsideKey--; };++ Entity._native = native;++ Entity._ncall = ncall;++ Entity._dist2 = dist2;++ Entity._pendingRemoval = pendingRemoval;++++ // Actual removal, deferred to end of tick (GMod behavior).++ function reallyRemove(ent) {++ if (ent._r.removed) return;++ ent._r.removeCallbacks.forEach(function (c) { try { c.fn(ent); } catch {} });++ if (typeof ent.OnRemove === "function") { try { ent.OnRemove(false); } catch {} }++ ent._r.removed = true;++ if (ent._r.isNative && native()) ncall(ent._key, "remove");++ Entity._unregister(ent);++ if (globalThis.hook) { try { hook.Run("EntityRemoved", ent, false); } catch {} }++ ent._r.deleteOnRemove.forEach(function (other) { if (other && other.Remove) other.Remove(); });++ }++ Entity._flushRemovals = function () {++ if (!pendingRemoval.length) return;++ var batch = pendingRemoval.splice(0, pendingRemoval.length);++ batch.forEach(reallyRemove);++ };++++ // ---- NULL entity ----++ NULL_ENTITY = new Entity(0);++ NULL_ENTITY._r.removed = true;++ NULL_ENTITY._r.class = "";++ NULL_ENTITY.IsValid = function () { return false; };++ NULL_ENTITY.isValid = NULL_ENTITY.IsValid;++ globalThis.NULL = NULL_ENTITY;++++ // ---- world entity (index 0 semantics kept simple) ----++ globalThis.Entity = Entity;++++ // ---- replication receivers (both realms register; only client applies) ----++ function ensureShellEntity(idx) {++ var e = Entity._get(idx);++ if (e && e.IsValid()) return e;++ var shell = new Entity(idx);++ shell._r.spawned = true;++ Entity._register(shell);++ return shell;++ }++ // net.js loads before entity.js in the bootstrap order, so this runs++ // immediately in practice.++ function registerSyncReceivers() {++ if (!globalThis.net || !net.__openvibe) return;++ if (isServer) {++ if (globalThis.util && util.AddNetworkString) {++ util.AddNetworkString("__ovnw");++ util.AddNetworkString("__ovdt");++ util.AddNetworkString("__ovchunk");++ }++ return;++ }++ net.Receive("__ovnw", function () {++ var idx = net.ReadInt(), type = net.ReadString(), key = net.ReadString(), v = net.ReadType();++ var ent = ensureShellEntity(idx);++ if (type === "Entity") v = Entity._get(v | 0);++ var old = ent._r.nw[key];++ ent._r.nw[key] = v;++ var proxy = ent._r.nwProxies[key];++ if (proxy) { try { proxy(ent, key, old, v); } catch {} }++ });++ net.Receive("__ovdt", function () {++ var idx = net.ReadInt(), name = net.ReadString(), v = net.ReadType();++ var ent = ensureShellEntity(idx);++ var slot = ent._r.dtSlots[name];++ if (slot && slot.type === "Entity") v = Entity._get(v | 0);++ var old = ent._r.dt[name];++ ent._r.dt[name] = v;++ var notify = ent._r.dtNotify[name];++ if (notify) { try { notify(ent, name, old, v); } catch {} }++ });++ }++ registerSyncReceivers();++++ if (OV && OV.log) OV.log("entity system ready (realm=" + (isServer ? "server" : "client") + ")");++})();nnew file mode 100644@@@ -0,0 +1,213 @@++// OpenVibe ents + scripted_ents libraries — GMod semantics.++// https://wiki.facepunch.com/gmod/ents https://wiki.facepunch.com/gmod/Scripted_Entities++//++// scripted_ents.Register(ENT, class) supports Base prototype chains and++// hot-patches live instances on re-register (GMod auto-refresh behavior).++(function () {++ if (globalThis.ents && globalThis.ents.__openvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++ var Entity = globalThis.Entity;++++ // ---- scripted entity registry ----++ var stored = Object.create(null); // class -> { t: def, proto, instances: [] }++++ function buildProto(def, className) {++ // Prototype chain: Entity.prototype <- base proto <- def++ var baseProto = Entity.prototype;++ if (def.Base && def.Base !== className) {++ var baseEntry = stored[def.Base];++ if (baseEntry) baseProto = baseEntry.proto;++ else if (OV && OV.warn) OV.warn("scripted_ents: base '" + def.Base + "' of '" + className + "' not registered yet");++ }++ var proto = Object.create(baseProto);++ for (var k in def) proto[k] = def[k];++ proto.ClassName = className;++ proto.BaseClass = baseProto;++ return proto;++ }++++ var scripted_ents = {++ __openvibe: true,++ Register: function (def, className) {++ className = String(className);++ if (!def || typeof def !== "object") throw new Error("scripted_ents.Register requires an ENT table");++ var proto = buildProto(def, className);++ var entry = stored[className];++ if (entry) {++ // Hot-patch: live instances jump to the new prototype; OnReloaded fires.++ entry.t = def;++ entry.proto = proto;++ entry.instances = entry.instances.filter(function (e) { return e.IsValid(); });++ entry.instances.forEach(function (e) {++ Object.setPrototypeOf(e, proto);++ if (typeof e.OnReloaded === "function") { try { e.OnReloaded(); } catch {} }++ });++ // Dependent classes need their chains rebuilt too.++ for (var cls in stored) {++ if (cls !== className && stored[cls].t.Base === className) {++ scripted_ents.Register(stored[cls].t, cls);++ }++ }++ } else {++ stored[className] = { t: def, proto: proto, instances: [] };++ }++ if (globalThis.baseclass) baseclass.Set("entity_" + className, proto);++ return stored[className];++ },++ GetStored: function (className) { return stored[String(className)] || null; },++ Get: function (className) {++ var entry = stored[String(className)];++ if (!entry) return null;++ var out = {};++ var p = entry.proto;++ // flatten the chain (GMod returns a base-merged copy)++ var keys = [];++ for (var k in p) keys.push(k);++ keys.forEach(function (key) { out[key] = p[key]; });++ return out;++ },++ GetList: function () { var out = {}; for (var k in stored) out[k] = stored[k].t; return out; },++ GetType: function (className) { var e = stored[String(className)]; return e ? e.t.Type || "anim" : null; },++ IsBasedOn: function (className, baseName) {++ var cur = String(className);++ var guard = 0;++ while (cur && guard++ < 32) {++ if (cur === String(baseName)) return true;++ var e = stored[cur];++ cur = e && e.t.Base;++ }++ return false;++ },++ GetMember: function (className, key) {++ var e = stored[String(className)];++ return e ? e.proto[key] : undefined;++ },++ _stored: stored++ };++++ // ---- construction ----++ function construct(className, key, clientside) {++ var ent = new Entity(key);++ ent._r.class = className;++ ent._r.clientside = !!clientside;++ var entry = stored[className];++ if (entry) {++ Object.setPrototypeOf(ent, entry.proto);++ ent._ENT = entry.t;++ entry.instances.push(ent);++ }++ Entity._register(ent);++ if (typeof ent.SetupDataTables === "function") {++ try { ent.SetupDataTables(); } catch (e) { OV && OV.error && OV.error("SetupDataTables " + className + ": " + (e && e.message)); }++ }++ if (globalThis.hook) { try { hook.Run("OnEntityCreated", ent); } catch {} }++ return ent;++ }++++ var ents = {++ __openvibe: true,++++ Create: function (className) {++ if (!isServer) { OV && OV.warn && OV.warn("ents.Create is server-only; use ents.CreateClientside"); return globalThis.NULL; }++ className = String(className);++ var entry = stored[className];++ var key;++ if (Entity._native()) {++ // Real engine entity (scripted classes become logical + native anchor++ // when the class is unknown to the engine).++ var res = OV.entCreate ? OV.entCreate(className) : null;++ if (res && res.entIndex > 0) {++ key = res.entIndex | 0;++ var ent = construct(className, key, false);++ ent._r.isNative = true;++ return ent;++ }++ if (!entry) return globalThis.NULL; // unknown engine class and not scripted++ }++ if (!entry && !Entity._native()) {++ // Pure-JS backend: allow logical entities for any class so gamemode++ // logic and tests behave; scripted classes get their prototypes.++ }++ key = Entity._allocLogical();++ return construct(className, key, false);++ },++++ CreateClientside: function (className) {++ if (isServer) { OV && OV.warn && OV.warn("ents.CreateClientside is client-only"); return globalThis.NULL; }++ return construct(String(className), Entity._allocClientside(), true);++ },++++ GetByIndex: function (idx) { return Entity._get(idx | 0); },++++ GetAll: function () {++ var out = [];++ Entity._each(function (e) { out.push(e); });++ if (globalThis.player && player.GetAll) {++ player.GetAll().forEach(function (p) { if (out.indexOf(p) < 0) out.push(p); });++ }++ return out;++ },++++ Iterator: function () {++ var all = ents.GetAll(), i = 0;++ return function () { return i < all.length ? [i, all[i++]] : null; };++ },++++ GetCount: function () { return ents.GetAll().length; },++++ FindByClass: function (pattern) {++ pattern = String(pattern);++ var rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");++ return ents.GetAll().filter(function (e) { return rx.test(e.GetClass()); });++ },++++ FindByName: function (pattern) {++ pattern = String(pattern);++ var rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");++ return ents.GetAll().filter(function (e) { return rx.test(e.GetName ? e.GetName() : ""); });++ },++++ FindByModel: function (model) {++ model = String(model);++ return ents.GetAll().filter(function (e) { return e.GetModel && e.GetModel() === model; });++ },++++ FindInSphere: function (origin, radius) {++ var r2 = (+radius || 0) * (+radius || 0);++ return ents.GetAll().filter(function (e) { return Entity._dist2(e.GetPos(), origin) <= r2; });++ },++++ FindInBox: function (mins, maxs) {++ return ents.GetAll().filter(function (e) {++ var p = e.GetPos();++ return p.x >= mins.x && p.x <= maxs.x && p.y >= mins.y && p.y <= maxs.y && p.z >= mins.z && p.z <= maxs.z;++ });++ },++++ FireTargets: function (name, activator, caller, useType, value) {++ ents.FindByName(name).forEach(function (e) { e.Use(activator, caller, useType, value); });++ }++ };++++ // ---- Think pump: entity Think scheduling + deferred removals ----++ if (globalThis.hook && typeof hook.Add === "function") {++ hook.Add("Think", "OpenVibeEntityThink", function () {++ var now = OV && OV.time ? OV.time() : 0;++ Entity._each(function (e) {++ if (!e._ENT || typeof e.Think !== "function" || !e._r.spawned) return;++ if (e._r.nextThink && now < e._r.nextThink) return;++ e._r.nextThink = 0;++ try { e.Think(); } catch (err) { OV && OV.error && OV.error("ENT:Think " + e.GetClass() + ": " + (err && err.message)); }++ });++ Entity._flushRemovals();++ return undefined;++ });++ }++++ globalThis.ents = ents;++ globalThis.scripted_ents = scripted_ents;++++ if (OV && OV.log) OV.log("ents library ready");++})();nnew file mode 100644@@@ -0,0 +1,252 @@++// OpenVibe file library — AddCSLuaFile/include equivalents + client script sync.++// https://wiki.facepunch.com/gmod/Global.AddCSLuaFile++//++// AddCSJSFile(path?) marks a js/-relative file for download to clients++// (server-only effect). These paths are auto-networked without marking:++// js/autorun/*.js, js/autorun/client/*.js,++// js/gamemodes/*/{manifest.json,shared.js,client.js},++// js/entities/** ,++// addons/*/{shared.js,client.js} + addon autorun/entities++//++// Sync protocol (net messages, server->client unless noted):++// __ovfs_manifest [{p, h, s}] full manifest {path, hash, size}++// __ovfs_request [paths] (client->server, rate limited)++// __ovfs_data path, content one file (net library auto-chunks)++// -> client caches under js/ov_downloads/<path> (OV.writeFile) and fires++// the "OVFilesSynced" hook when the diff is satisfied.++(function () {++ if (globalThis.file && globalThis.file.__ovOpenvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++++ var marked = Object.create(null); // js-relative path -> true (explicit AddCSJSFile)++ var currentFile = null; // set by the loader while a file executes++++ function normalize(p) {++ var parts = String(p).replace(/\\/g, "/").split("/");++ var out = [];++ for (var i = 0; i < parts.length; i++) {++ var s = parts[i];++ if (s === "" || s === ".") continue;++ if (s === "..") { out.pop(); continue; }++ out.push(s);++ }++ return out.join("/");++ }++++ function dirname(p) {++ var i = p.lastIndexOf("/");++ return i <= 0 ? "" : p.slice(0, i);++ }++++ // Resolve a script path GMod-style: relative to js/ first, then relative to++ // the currently executing file's directory.++ function resolveScript(p) {++ p = normalize(p);++ var candidates = [];++ if (p.indexOf("js/") === 0 || p.indexOf("addons/") === 0) candidates.push(p);++ else {++ candidates.push("js/" + p);++ if (currentFile) candidates.push(normalize(dirname(currentFile) + "/" + p));++ }++ for (var i = 0; i < candidates.length; i++) {++ if (OV && OV.fileExists && OV.fileExists(candidates[i])) return candidates[i];++ }++ // client: fall back to the synced download cache++ if (!isServer) {++ for (var j = 0; j < candidates.length; j++) {++ var dl = "js/ov_downloads/" + candidates[j];++ if (OV && OV.fileExists && OV.fileExists(dl)) return dl;++ }++ }++ return null;++ }++++ function readScript(p) {++ var full = resolveScript(p);++ return full ? { path: full, code: OV.readFile(full) } : null;++ }++++ // ---- AddCSJSFile ----++ function addCSJSFile(p) {++ if (!isServer) return; // no-op on the client (safe in shared files)++ var path = p === undefined ? currentFile : resolveScript(p);++ if (!path && p !== undefined) path = normalize(String(p).indexOf("js/") === 0 ? String(p) : "js/" + String(p));++ if (path) marked[path] = true;++ }++++ // ---- include ----++ function include(p) {++ var found = readScript(p);++ if (!found || found.code == null) {++ var msg = "include('" + p + "'): file doesn't exist";++ if (OV && OV.error) OV.error(msg); else throw new Error(msg);++ return undefined;++ }++ var prev = currentFile;++ currentFile = found.path;++ try {++ return (0, eval)(found.code + "\n//# sourceURL=" + found.path);++ } catch (e) {++ if (OV && OV.error) OV.error("include('" + p + "'): " + (e && e.stack ? e.stack : e));++ return undefined;++ } finally {++ currentFile = prev;++ }++ }++++ // ---- auto-networked path discovery ----++ function listJs(dir) {++ if (!OV || !OV.listDir) return [];++ var names = OV.listDir(dir, "*") || [];++ return names.filter(function (n) { return /\.(js|json)$/.test(n); }).map(function (n) { return dir + "/" + n; });++ }++++ function autoNetworkedFiles() {++ var out = [];++ out.push.apply(out, listJs("js/autorun"));++ out.push.apply(out, listJs("js/autorun/client"));++ var modes = (OV && OV.listDir ? OV.listDir("js/gamemodes", "*") : []) || [];++ modes.forEach(function (m) {++ if (m.charAt(0) === ".") return;++ ["manifest.json", "shared.js", "client.js"].forEach(function (f) {++ var p = "js/gamemodes/" + m + "/" + f;++ if (OV.fileExists(p)) out.push(p);++ });++ });++ var entities = (OV && OV.listDir ? OV.listDir("js/entities", "*") : []) || [];++ entities.forEach(function (e) {++ if (e.charAt(0) === ".") return;++ var single = "js/entities/" + e;++ if (/\.js$/.test(e) && OV.fileExists(single)) { out.push(single); return; }++ ["shared.js", "cl_init.js"].forEach(function (f) {++ var p = "js/entities/" + e + "/" + f;++ if (OV.fileExists(p)) out.push(p);++ });++ });++ var addons = (OV && OV.listDir ? OV.listDir("addons", "*") : []) || [];++ addons.forEach(function (a) {++ if (a === "node_modules" || a.charAt(0) === ".") return;++ ["shared.js", "client.js", "addon.json"].forEach(function (f) {++ var p = "addons/" + a + "/" + f;++ if (OV.fileExists(p)) out.push(p);++ });++ });++ return out;++ }++++ function buildManifest() {++ var files = autoNetworkedFiles();++ for (var p in marked) if (files.indexOf(p) < 0) files.push(p);++ return files.map(function (p) {++ var code = OV.readFile(p);++ return { p: p, h: code != null && globalThis.util ? util.CRC(code) : "0", s: code ? code.length : 0 };++ }).filter(function (f) { return f.s > 0; });++ }++++ var file = {++ __ovOpenvibe: true,++ Exists: function (p) { return !!resolveScript(p); },++ Read: function (p) { var f = readScript(p); return f ? f.code : null; },++ Write: function (p, content) {++ // Client-side cache writes only (download dir), mirroring GMod's data/ jail.++ if (OV && OV.writeFile) return OV.writeFile("js/ov_downloads/" + normalize(p), String(content));++ return false;++ },++ BuildClientManifest: buildManifest,++ _setCurrent: function (p) { currentFile = p ? normalize(p) : null; },++ _getCurrent: function () { return currentFile; },++ _resolve: resolveScript,++ _marked: marked++ };++++ globalThis.file = file;++ globalThis.include = include;++ globalThis.AddCSJSFile = addCSJSFile;++ globalThis.AddCSLuaFile = addCSJSFile; // muscle-memory alias++++ // ---- sync protocol ----++ function poolNames() {++ if (isServer && globalThis.util && util.AddNetworkString) {++ util.AddNetworkString("__ovfs_manifest");++ util.AddNetworkString("__ovfs_request");++ util.AddNetworkString("__ovfs_data");++ }++ }++ poolNames();++++ if (globalThis.net && net.__openvibe) {++ if (isServer) {++ // Send the manifest shortly after a player finishes joining.++ if (globalThis.hook) {++ hook.Add("PlayerInitialSpawn", "OpenVibeFileSync", function (ply) {++ var send = function () {++ try {++ net.Start("__ovfs_manifest");++ net.WriteTable(buildManifest());++ net.Send(ply);++ } catch (e) { OV && OV.warn && OV.warn("file sync manifest: " + (e && e.message)); }++ };++ if (globalThis.timer && timer.simple) timer.simple(2, send); else send();++ return undefined;++ });++ }++ net.SetRateLimit("__ovfs_request", 10);++ net.Receive("__ovfs_request", function (len, ply) {++ if (!ply) return;++ var paths = net.ReadTable(true) || [];++ if (!Array.isArray(paths)) return;++ var manifest = buildManifest();++ var allowed = {};++ manifest.forEach(function (f) { allowed[f.p] = true; });++ paths.slice(0, 64).forEach(function (p) {++ p = normalize(String(p));++ if (!allowed[p]) return; // only networked files may be requested++ var code = OV.readFile(p);++ if (code == null) return;++ net.Start("__ovfs_data");++ net.WriteString(p);++ net.WriteString(code);++ net.Send(ply);++ });++ });++ } else {++ var pendingSync = null;++ net.Receive("__ovfs_manifest", function () {++ var manifest = net.ReadTable(true) || [];++ var missing = [];++ manifest.forEach(function (f) {++ var local = OV.readFile(f.p);++ var cached = OV.readFile("js/ov_downloads/" + f.p);++ var have = local != null ? util.CRC(local) : (cached != null ? util.CRC(cached) : null);++ if (have !== f.h) missing.push(f.p);++ });++ if (!missing.length) {++ if (globalThis.hook) hook.Run("OVFilesSynced", []);++ return;++ }++ pendingSync = { missing: {}, count: missing.length };++ missing.forEach(function (p) { pendingSync.missing[p] = true; });++ net.Start("__ovfs_request");++ net.WriteTable(missing, true);++ net.SendToServer();++ });++ net.Receive("__ovfs_data", function () {++ var p = net.ReadString();++ var content = net.ReadString();++ file.Write(p, content);++ if (pendingSync && pendingSync.missing[p]) {++ delete pendingSync.missing[p];++ pendingSync.count--;++ if (pendingSync.count <= 0) {++ pendingSync = null;++ if (globalThis.hook) hook.Run("OVFilesSynced", []);++ }++ }++ });++ }++ }++++ if (OV && OV.log) OV.log("file library ready (AddCSJSFile/include)");++})();@@@ -1,6 +1,11 @@++// OpenVibe gamemode library — GMod gamemode.Register / DeriveGamemode /++// baseclass parity, layered on prototype inheritance.++// https://wiki.facepunch.com/gmod/Gamemode_Creation(function () {-- let base = null;-- let current = null;++ let base = null; // the "base" gamemode table++ let current = null; // active GAMEMODE++ const registered = Object.create(null); // name -> gm table++ const baseclasses = Object.create(null); // "gamemode_<name>" -> tablefunction publish(gm) {current = gm;@@@ -11,58 +16,96 @@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;}++ // gamemode.Register(GM, name, derivedName) — wires inheritance, stores the++ // table under the name, publishes baseclass. Mirrors the engine step that++ // runs after gamemode files finish executing.++ function register(gm, name, derivedName) {++ if (!gm || typeof gm !== "object") throw new Error("gamemode.Register(gm, name) requires an object");++ name = String(name || gm.mode || gm.FolderName || "unnamed");++++ let parent = null;++ if (derivedName && registered[derivedName]) parent = registered[derivedName];++ else if (derivedName === "base" || !derivedName) parent = base;++++ const out = (parent && Object.getPrototypeOf(gm) !== parent) ? derive(gm, parent) : gm;++ out.FolderName = out.FolderName || name;++ out.DerivedFrom = derivedName || (parent === base && name !== "base" ? "base" : undefined);++++ registered[name] = out;++ baseclasses["gamemode_" + name] = out;++ if (name === "base") base = out;++ 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);++ base = register(gm, gm.mode || "base");++ return publish(base);}---- return publish(derive(gm, base));++ return publish(register(gm, gm.mode || gm.FolderName, gm.DerivedFrom || "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;++ base = register(gm, "base");++ if (!current) publish(base);++ return base;}-- function get() {-- return current;-- }++ function get() { return current; }++ function getBase() { return base; }++ function getRegistered(name) { return name ? registered[String(name)] || null : Object.keys(registered); }-- function getBase() {-- return base;++ // gamemode.call — the C++ bridge entry point for every engine-fired hook.++ // Wraps native player handles into framework Player objects when the player++ // library is loaded, then dispatches through the hook library.++ function wrapArg(a) {++ if (a && typeof a === "object" && !a.__ovEntity &&++ typeof a.userId === "function" && globalThis.Player &&++ typeof Player.fromNative === "function") {++ try { return Player.fromNative(a); } catch { return a; }++ }++ return a;}function call(name, ...args) {-- return hook.call(name, current, ...args);++ const wrapped = args.map(wrapArg);++ return hook.call(name, current, ...wrapped);}globalThis.gamemode = {-- set,-- setBase,-- derive,-- get,-- getBase,-- call,++ set, setBase, derive, get, getBase, call,++ register,Set: set,SetBase: setBase,Derive: derive,Get: get,GetBase: getBase,-- Call: call++ Call: call,++ Register: register,++ GetRegistered: getRegistered++ };++++ // DeriveGamemode("name") — GMod global; records the parent for the gamemode++ // file currently executing. gamemode.set applies it via GM.DerivedFrom.++ globalThis.DeriveGamemode = function (parentName) {++ if (globalThis.GM && typeof GM === "object") GM.DerivedFrom = String(parentName);++ return parentName;++ };++++ // baseclass library (https://wiki.facepunch.com/gmod/baseclass)++ globalThis.baseclass = {++ Set: function (name, tbl) { baseclasses[String(name)] = tbl; },++ Get: function (name) { return baseclasses[String(name)] || null; }};globalThis.GM = null;@@@ -1,5 +1,15 @@++// OpenVibe hook library — GMod hook semantics.++// https://wiki.facepunch.com/gmod/Hook_Library_Usage++//++// - hook.Add(event, id, fn): id is a string OR an object with IsValid();++// re-adding with the same (event, id) replaces the hook.++// - Object identifiers: if !IsValid(id) at call time the hook is silently++// removed; otherwise the identifier is prepended as the first argument.++// - hook.Call(event, gm, ...): registered hooks run first (insertion order);++// the first hook returning anything other than undefined short-circuits++// (false counts as a result); otherwise the gamemode method runs.(function () {-- const buckets = Object.create(null);++ const buckets = Object.create(null); // event -> [{id, obj, fn}]function ensure(name) {name = String(name || "");@@@ -9,54 +19,71 @@function logError(name, id, err) {const message = err && err.stack ? err.stack : String(err);++ const label = typeof id === "string" ? id : "<object>";if (globalThis.OV && typeof OV.error === "function") {-- OV.error(`hook ${name}/${id}: ${message}`);++ OV.error(`hook ${name}/${label}: ${message}`);} else {-- console.error(`hook ${name}/${id}: ${message}`);++ console.error(`hook ${name}/${label}: ${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");-- }++ function objValid(obj) {++ if (obj == null) return false;++ if (typeof obj.IsValid === "function") { try { return !!obj.IsValid(); } catch { return false; } }++ if (typeof obj.isValid === "function") { try { return !!obj.isValid(); } catch { return false; } }++ return true; // plain object without IsValid stays registered (GMod requires IsValid, we are lenient)++ }-- 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 add(name, id, fn) {++ if (!name || id == null || typeof fn !== "function") {++ throw new Error("hook.Add(name, id, fn) requires a hook name, id, and function");++ }++ const isObj = typeof id === "object" || typeof id === "function";++ if (!isObj && typeof id !== "string" && typeof id !== "number") {++ throw new Error("hook.Add identifier must be a string or an object with IsValid()");++ }++ const list = ensure(name);++ const key = isObj ? id : String(id);++ for (let i = list.length - 1; i >= 0; --i) {++ if (list[i].key === key) list.splice(i, 1);++ }++ list.push({ key, id: isObj ? "<object>" : String(id), obj: isObj ? id : null, 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);-- }++ const list = buckets[String(name || "")];++ if (!list) return;++ const isObj = typeof id === "object" || typeof id === "function";++ const key = isObj ? id : String(id == null ? "" : id);++ for (let i = list.length - 1; i >= 0; --i) {++ if (list[i].key === key) 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) {++ const list = buckets[String(name || "")];++ if (list && list.length) {++ const snapshot = list.slice();++ for (const item of snapshot) {++ if (item.obj) {++ if (!objValid(item.obj)) { remove(name, item.obj); continue; }++ try {++ const result = item.fn(item.obj, ...args);++ if (result !== undefined) return result;++ } catch (err) { logError(name, item.id, err); }++ } else {try {const result = item.fn(...args);if (result !== undefined) return result;-- } catch (err) {-- logError(name, item.id, err);-- }++ } catch (err) { logError(name, item.id, err); }}}++ }-- if (gm && typeof gm[name] === "function") {-- return gm[name](...args);-- }---- return undefined;++ if (gm && typeof gm[name] === "function") {++ return gm[name](...args);++ }++ return undefined;}function run(name, ...args) {@@@ -66,24 +93,21 @@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;++ function getTable(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, remove, run, call,++ list: getTable,Add: add,Remove: remove,Run: run,Call: call,-- GetTable: list++ GetTable: getTable};})();nnew file mode 100644@@@ -0,0 +1,181 @@++// OpenVibe loader — GMod Lua_Loading_Order equivalent for the js/ tree.++// https://wiki.facepunch.com/gmod/Lua_Loading_Order++//++// Per realm: autorun (shared, then realm dir; alphabetical) -> gamemode chain++// (base first, then the active mode: shared -> realm entry) -> scripted++// entities -> addons. Files are deduped, so hosts that pre-load gamemode++// files (the older embedded C++ path) can still call loadAll() safely.++//++// Also provides the console-exec entry points:++// OVLoader.runString(code, id) — js_run / js_run_cl++// OVLoader.openScript(path) — js_openscript / js_openscript_cl++(function () {++ if (globalThis.OVLoader && globalThis.OVLoader.__openvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++ var realmDir = isServer ? "server" : "client";++ var loadedFiles = Object.create(null); // path -> true++++ function log(m) { if (OV && OV.log) OV.log("[loader] " + m); }++ function warn(m) { if (OV && OV.warn) OV.warn("[loader] " + m); }++++ function execFile(path, force) {++ if (!force && loadedFiles[path]) return true;++ var code = OV.readFile(path);++ if (code == null) return false;++ loadedFiles[path] = true;++ var prev = globalThis.file ? file._getCurrent() : null;++ if (globalThis.file) file._setCurrent(path);++ try {++ (0, eval)(code + "\n//# sourceURL=" + path);++ return true;++ } catch (e) {++ warn("failed " + path + ": " + (e && e.stack ? e.stack : e));++ return false;++ } finally {++ if (globalThis.file) file._setCurrent(prev);++ }++ }++++ function listSorted(dir, filterRx) {++ if (!OV || !OV.listDir) return [];++ var names = (OV.listDir(dir, "*") || []).slice();++ names.sort();++ return names.filter(function (n) { return filterRx.test(n); });++ }++++ // ---- autorun ----++ function loadAutorun() {++ listSorted("js/autorun", /\.js$/).forEach(function (f) { execFile("js/autorun/" + f); });++ listSorted("js/autorun/" + realmDir, /\.js$/).forEach(function (f) { execFile("js/autorun/" + realmDir + "/" + f); });++ }++++ // ---- gamemodes ----++ function readManifest(mode) {++ var p = "js/gamemodes/" + mode + "/manifest.json";++ if (!OV.fileExists(p)) return null;++ try { return JSON.parse(OV.readFile(p)); } catch (e) { warn("bad manifest for " + mode); return null; }++ }++++ function loadGamemodeFiles(mode) {++ var dir = "js/gamemodes/" + mode;++ var sharedPath = dir + "/shared.js";++ var entryPath = dir + "/" + (isServer ? "server.js" : "client.js");++ if (OV.fileExists(sharedPath)) execFile(sharedPath);++ if (OV.fileExists(entryPath)) execFile(entryPath);++ }++++ // Load a gamemode chain: recurse into the base first (GMod DeriveGamemode).++ function loadGamemodeChain(mode, seen) {++ seen = seen || {};++ if (seen[mode]) { warn("gamemode base cycle at '" + mode + "'"); return; }++ seen[mode] = true;++ var manifest = readManifest(mode);++ var baseName = manifest && manifest.base && manifest.base !== mode ? manifest.base : (mode !== "base" ? "base" : null);++ if (baseName) loadGamemodeChain(baseName, seen);++ loadGamemodeFiles(mode);++ }++++ // ---- scripted entities ----++ function loadEntities(root) {++ root = root || "js/entities";++ var names = listSorted(root, /./);++ names.forEach(function (name) {++ if (name.charAt(0) === ".") return;++ var single = root + "/" + name;++ if (/\.js$/.test(name)) {++ // single-file SENT: runs in both realms; sets globalThis.ENT++ runEntityFile(name.replace(/\.js$/, ""), [single]);++ return;++ }++ // folder form: shared.js always; init.js server; cl_init.js client++ var files = [];++ if (OV.fileExists(single + "/shared.js")) files.push(single + "/shared.js");++ var entry = single + "/" + (isServer ? "init.js" : "cl_init.js");++ if (OV.fileExists(entry)) files.push(entry);++ if (files.length) runEntityFile(name, files);++ });++ }++++ function runEntityFile(className, files) {++ if (!globalThis.scripted_ents) return;++ var prevENT = globalThis.ENT;++ globalThis.ENT = { Type: "anim", Base: "base_anim", ClassName: className };++ try {++ files.forEach(function (f) { execFile(f); });++ if (globalThis.ENT && typeof globalThis.ENT === "object") {++ scripted_ents.Register(globalThis.ENT, className);++ }++ } finally {++ globalThis.ENT = prevENT;++ }++ }++++ // ---- base scripted-entity classes (GMod engine bases) ----++ function registerBaseEntities() {++ if (!globalThis.scripted_ents) return;++ if (!scripted_ents.GetStored("base_entity")) scripted_ents.Register({ Type: "anim" }, "base_entity");++ if (!scripted_ents.GetStored("base_anim")) scripted_ents.Register({ Type: "anim", Base: "base_entity", AutomaticFrameAdvance: false }, "base_anim");++ if (!scripted_ents.GetStored("base_point")) scripted_ents.Register({ Type: "point", Base: "base_entity" }, "base_point");++ if (!scripted_ents.GetStored("base_brush")) scripted_ents.Register({ Type: "brush", Base: "base_entity" }, "base_brush");++ if (!scripted_ents.GetStored("base_gmodentity")) {++ scripted_ents.Register({++ Type: "anim", Base: "base_anim",++ SetPlayer: function (ply) { this.SetNWEntity("Founder", ply); },++ GetPlayer: function () { return this.GetNWEntity("Founder", globalThis.NULL); }++ }, "base_gmodentity");++ }++ }++++ var OVLoader = {++ __openvibe: true,++++ // Full realm load. opts: { mode, gamemodes: false to skip gamemode files }++ loadAll: function (opts) {++ opts = opts || {};++ var mode = opts.mode || (OV && OV.getMode ? OV.getMode() : "base");++ registerBaseEntities();++ loadAutorun();++ if (opts.gamemodes !== false) loadGamemodeChain(mode);++ loadEntities();++ if (globalThis.Addon && Addon.loadAll) Addon.loadAll();++ log("realm load complete (mode=" + mode + ", realm=" + realmDir + ")");++ return true;++ },++++ // js_run / js_run_cl++ runString: function (code, id) {++ try {++ var result = (0, eval)(String(code) + "\n//# sourceURL=" + (id || "js_run"));++ return { ok: true, result: result };++ } catch (e) {++ var msg = e && e.stack ? e.stack : String(e);++ if (OV && OV.error) OV.error("js_run: " + msg);++ return { ok: false, error: msg };++ }++ },++++ // js_openscript / js_openscript_cl — path relative to js/ (GMod: lua/)++ openScript: function (path) {++ var resolved = globalThis.file ? file._resolve(path) : ("js/" + path);++ if (!resolved || !OV.fileExists(resolved)) {++ warn("js_openscript: no such script '" + path + "'");++ return false;++ }++ log("running script " + resolved);++ return execFile(resolved, true); // force: openscript always re-runs++ },++++ reloadFile: function (path) {++ var resolved = globalThis.file ? file._resolve(path) : null;++ if (resolved) delete loadedFiles[resolved];++ return resolved ? execFile(resolved, true) : false;++ },++++ loadedFiles: loadedFiles++ };++++ globalThis.OVLoader = OVLoader;++ if (OV && OV.log) OV.log("loader ready (realm=" + realmDir + ")");++})();@@@ -1,26 +1,29 @@--// OpenVibe net library — GMod-style networking for the JS platform.++// OpenVibe net library — GMod net semantics for the JS platform.// https://wiki.facepunch.com/gmod/Net_Library_Usage//--// Messages are composed with net.Start(name) + net.Write*(...) and sent with--// net.Send/Broadcast (server->client) or net.SendToServer (client->server).--// Receivers register net.Receive(name, fn) and read fields in the same order--// they were written.++// Faithful behaviors:++// - util.AddNetworkString pooling required server-side before net.Start++// - one implicit write buffer; Start resets it; Abort discards++// - receiver names are case-insensitive; ONE handler per name++// - server receivers get (len, ply) with the authoritative sender++// - 64KB payload cap; read-past-end returns type defaults++// - per-player per-message rate limiting (net.SetRateLimit)//--// Transport: fields are collected as a typed list, serialized to JSON, and--// base64-encoded so the payload is a single transport-safe token. The C++--// bridge carries (name, payloadB64):--// server->client : OV.netEmit(targets, name, payloadB64) -> usermessage--// client->server : OV.netSendToServer(name, payloadB64) -> forwarded cmd--// Inbound messages arrive as the "OVNetReceive" hook fired from C++.--//--// NOTE: The client realm has no JS runtime yet, so net.Receive on the client--// is inert until the client JS runtime lands. Server-side receive (from--// client->server) and server-side send are fully live today.++// Transport: fields are collected as a typed list, serialized to JSON and++// base64-encoded. The C++ bridge carries (name, payloadB64):++// server->client : OV.netEmit(targetsCsv, name, payloadB64) -> "OVNet" usermessage++// client->server : OV.netSendToServer(name, payloadB64) -> "ov_net" command++// Source usermessages cap at ~255 bytes, so payloads are chunked over the++// reserved "__ovchunk" message and reassembled before dispatch.++// Inbound messages arrive as the "OVNetReceive" hook fired from the bridge.(function () {if (globalThis.net && globalThis.net.__openvibe) return;var OV = globalThis.OV;-- var isServer = OV && OV.isServer ? OV.isServer() : true;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++++ var MAX_PAYLOAD_BYTES = 65533; // GMod per-message cap++ var CHUNK_SIZE = 180; // base64 chars per usermessage-safe chunk// --- base64 over UTF-8 (QuickJS has no btoa/atob) ---var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";@@@ -57,7 +60,7 @@}function b64decode(str) {var bytes = [], i = 0;-- str = str.replace(/=+$/, "");++ str = String(str).replace(/=+$/, "");while (i < str.length) {var e0 = B64.indexOf(str[i++]), e1 = B64.indexOf(str[i++]);var e2 = i < str.length ? B64.indexOf(str[i++]) : -1;@@@ -69,118 +72,283 @@return utf8Decode(bytes);}-- var networkStrings = Object.create(null); // parity with util.AddNetworkString-- var receivers = Object.create(null); // name -> fn(len, ply)++ function warn(m) { if (OV && OV.warn) OV.warn("net: " + m); }++ function error(m) { if (OV && OV.error) OV.error("net: " + m); }++++ var receivers = Object.create(null); // lowercase name -> fn(len, ply)++ var writeBuf = null; // { name, fields: [{t,v}], unreliable }++ var readBuf = null; // { fields, cursor }++++ // --- rate limiting (server inbound) ---++ var rateLimits = Object.create(null); // lowercase name -> per-second cap++ var DEFAULT_RATE = 30;++ var rateState = Object.create(null); // "name:userId" -> {sec, count}++ function rateOk(name, ply) {++ if (!isServer || !ply || typeof ply.userId !== "function") return true;++ var cap = rateLimits[name] !== undefined ? rateLimits[name] : DEFAULT_RATE;++ if (cap <= 0) return true;++ var now = Math.floor((OV && OV.time ? OV.time() : 0));++ var key = name + ":" + ply.userId();++ var st = rateState[key];++ if (!st || st.sec !== now) { rateState[key] = { sec: now, count: 1 }; return true; }++ st.count++;++ return st.count <= cap;++ }-- var writeBuf = null; // { name, fields: [{t,v}] }-- var readBuf = null; // { fields, cursor }++ function pooled(name) {++ return globalThis.util && util.NetworkStringToID ? util.NetworkStringToID(name) !== 0 : true;++ }function push(t, v) {-- if (!writeBuf) { OV.warn("net.Write* called without net.Start"); return; }++ if (!writeBuf) { warn("net.Write* called without net.Start"); return; }writeBuf.fields.push({ t: t, v: v });}-- function readNext(expectType) {++ function readNext() {if (!readBuf || readBuf.cursor >= readBuf.fields.length) return undefined;var f = readBuf.fields[readBuf.cursor++];return f ? f.v : undefined;}function serialize() {-- return b64encode(JSON.stringify({ n: writeBuf.name, f: writeBuf.fields }));++ var payload = b64encode(JSON.stringify({ n: writeBuf.name, f: writeBuf.fields, u: writeBuf.unreliable ? 1 : 0 }));++ if (payload.length > MAX_PAYLOAD_BYTES) {++ error("message '" + writeBuf.name + "' exceeds 64KB payload cap (" + payload.length + " bytes) — dropped");++ return null;++ }++ return payload;++ }++++ // --- chunked outbound (server->client path over 255-byte usermessages) ---++ var chunkSeq = 0;++ function emitChunked(idsCsv, name, payload) {++ if (!OV || !OV.netEmit) { warn("no netEmit bridge"); return; }++ if (payload.length <= CHUNK_SIZE) { OV.netEmit(idsCsv, name, payload); return; }++ var msgId = String(++chunkSeq);++ var total = Math.ceil(payload.length / CHUNK_SIZE);++ for (var i = 0; i < total; i++) {++ var part = payload.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);++ // header "msgId:seq:total:name|" + data — single transport-safe token++ OV.netEmit(idsCsv, "__ovchunk", b64encode(msgId + ":" + i + ":" + total + ":" + name) + "|" + part);++ }++ }++ function sendToServerChunked(name, payload) {++ if (!OV || !OV.netSendToServer) { warn("net.SendToServer unavailable (no client bridge)"); return; }++ if (payload.length <= CHUNK_SIZE) { OV.netSendToServer(name, payload); return; }++ var msgId = String(++chunkSeq);++ var total = Math.ceil(payload.length / CHUNK_SIZE);++ for (var i = 0; i < total; i++) {++ var part = payload.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);++ OV.netSendToServer("__ovchunk", b64encode(msgId + ":" + i + ":" + total + ":" + name) + "|" + part);++ }++ }++++ // --- chunk reassembly (both realms) ---++ var assembling = Object.create(null); // "senderKey:msgId" -> {total, parts[], got, name}++ function onChunk(payloadB64, ply) {++ var bar = payloadB64.indexOf("|");++ if (bar < 0) return null;++ var header;++ try { header = b64decode(payloadB64.slice(0, bar)); } catch { return null; }++ var m = /^([^:]+):(\d+):(\d+):(.*)$/.exec(header);++ if (!m) return null;++ var senderKey = ply && typeof ply.userId === "function" ? String(ply.userId()) : "s";++ var key = senderKey + ":" + m[1];++ var seq = m[2] | 0, total = m[3] | 0, name = m[4];++ if (total < 1 || total > 4096 || seq >= total) return null;++ var st = assembling[key];++ if (!st) st = assembling[key] = { total: total, parts: [], got: 0, name: name };++ if (st.parts[seq] === undefined) { st.parts[seq] = payloadB64.slice(bar + 1); st.got++; }++ if (st.got < st.total) return null;++ delete assembling[key];++ return { name: st.name, payload: st.parts.join("") };++ }++++ function entIndexOf(v) {++ if (v == null) return 0;++ if (typeof v === "number") return v | 0;++ if (typeof v.EntIndex === "function") return v.EntIndex() | 0;++ if (typeof v.entIndex === "function") return v.entIndex() | 0;++ return 0;++ }++ function userIdOf(v) {++ if (v == null) return -1;++ if (typeof v === "number") return v | 0;++ if (typeof v.UserID === "function") return v.UserID() | 0;++ if (typeof v.userId === "function") return v.userId() | 0;++ return -1;++ }++ function resolveEntity(idx) {++ idx = idx | 0;++ if (globalThis.ents && ents.GetByIndex) return ents.GetByIndex(idx);++ if (OV && OV.playerByUserId) return OV.playerByUserId(idx); // legacy fallback++ return null;++ }++ function resolvePlayer(uid) {++ uid = uid | 0;++ if (globalThis.player && player.GetByUserID) return player.GetByUserID(uid);++ if (OV && OV.playerByUserId) return OV.playerByUserId(uid);++ return null;}var net = {__openvibe: true,Start: function (name, unreliable) {-- writeBuf = { name: String(name), fields: [], unreliable: !!unreliable };-- return net;++ name = String(name);++ if (isServer && !pooled(name)) {++ error("net.Start('" + name + "') — name not pooled; call util.AddNetworkString first");++ writeBuf = null;++ return false;++ }++ writeBuf = { name: name, fields: [], unreliable: !!unreliable };++ return true;},-- // Writers (bits arg accepted for GMod parity; JSON keeps full precision).++ Abort: function () { writeBuf = null; },++++ // ---- writers (bit widths accepted for GMod parity; JSON keeps precision) ----++ WriteBit: function (v) { push("b", !!v); return net; },++ WriteBool: function (v) { push("b", !!v); return net; },WriteInt: function (v, bits) { push("i", v | 0); return net; },WriteUInt: function (v, bits) { push("u", v >>> 0); return net; },++ WriteUInt64: function (v) { push("u64", String(v)); return net; },WriteFloat: function (v) { push("f", Number(v)); return net; },WriteDouble: function (v) { push("f", Number(v)); return net; },-- WriteBool: function (v) { push("b", !!v); return net; },WriteString: function (v) { push("s", String(v)); return net; },-- WriteEntity: function (v) { push("e", v && v.entIndex ? v.entIndex() : (v | 0)); return net; },-- WriteTable: function (v) { push("t", v); return net; },-- WriteVector: function (v) { push("v", v); return net; },---- // Readers (call in the same order as written).-- ReadInt: function () { return readNext("i") | 0; },-- ReadUInt: function () { return readNext("u") >>> 0; },-- ReadFloat: function () { return Number(readNext("f")); },-- ReadDouble: function () { return Number(readNext("f")); },-- ReadBool: function () { return !!readNext("b"); },-- ReadString: function () { var v = readNext("s"); return v === undefined ? "" : String(v); },-- ReadEntity: function () { var id = readNext("e") | 0; return OV.playerByUserId ? OV.playerByUserId(id) : id; },-- ReadTable: function () { return readNext("t"); },-- ReadVector: function () { return readNext("v"); },++ WriteData: function (v, len) { var s = String(v); push("d", len === undefined ? s : s.slice(0, len)); return net; },++ WriteEntity: function (v) { push("e", entIndexOf(v)); return net; },++ WritePlayer: function (v) { push("p", userIdOf(v)); return net; },++ WriteVector: function (v) { push("v", v ? { x: +v.x || 0, y: +v.y || 0, z: +v.z || 0 } : { x: 0, y: 0, z: 0 }); return net; },++ WriteNormal: function (v) { return net.WriteVector(v); },++ WriteAngle: function (v) { push("a", v ? { p: +v.p || +v.pitch || 0, y: +v.y || +v.yaw || 0, r: +v.r || +v.roll || 0 } : { p: 0, y: 0, r: 0 }); return net; },++ WriteColor: function (v, writeAlpha) {++ var c = v || {};++ var out = { r: c.r | 0, g: c.g | 0, b: c.b | 0 };++ if (writeAlpha === undefined || writeAlpha) out.a = c.a === undefined ? 255 : c.a | 0;++ push("c", out); return net;++ },++ WriteTable: function (v, sequential) { push("t", v); return net; },++ WriteType: function (v) { push("y", v); return net; },++++ // ---- readers (same order as written; past-end -> type default) ----++ ReadBit: function () { return readNext() ? 1 : 0; },++ ReadBool: function () { return !!readNext(); },++ ReadInt: function (bits) { return readNext() | 0; },++ ReadUInt: function (bits) { return readNext() >>> 0; },++ ReadUInt64: function () { var v = readNext(); return v === undefined ? "0" : String(v); },++ ReadFloat: function () { var v = readNext(); return v === undefined ? 0 : Number(v); },++ ReadDouble: function () { var v = readNext(); return v === undefined ? 0 : Number(v); },++ ReadString: function () { var v = readNext(); return v === undefined ? "" : String(v); },++ ReadData: function (len) { var v = readNext(); v = v === undefined ? "" : String(v); return len === undefined ? v : v.slice(0, len); },++ ReadEntity: function () { return resolveEntity(readNext() | 0); },++ ReadPlayer: function () { return resolvePlayer(readNext() | 0); },++ ReadVector: function () { var v = readNext(); return v && typeof v === "object" ? v : { x: 0, y: 0, z: 0 }; },++ ReadNormal: function () { return net.ReadVector(); },++ ReadAngle: function () { var v = readNext(); return v && typeof v === "object" ? v : { p: 0, y: 0, r: 0 }; },++ ReadColor: function (hasAlpha) {++ var v = readNext();++ var c = v && typeof v === "object" ? v : { r: 0, g: 0, b: 0 };++ if (c.a === undefined) c.a = 255;++ return c;++ },++ ReadTable: function (sequential) { var v = readNext(); return v === undefined ? {} : v; },++ ReadType: function () { return readNext(); },++++ BytesWritten: function () { return writeBuf ? JSON.stringify(writeBuf.fields).length + 3 : 0; },++ BytesLeft: function () {++ if (!readBuf) return 0;++ var rest = readBuf.fields.slice(readBuf.cursor);++ return rest.length ? JSON.stringify(rest).length : 0;++ },Receive: function (name, fn) {if (typeof fn !== "function") throw new Error("net.Receive requires a function");-- receivers[String(name)] = fn;++ receivers[String(name).toLowerCase()] = fn;},-- // server -> client. ids are passed to C++ as a comma-separated list of-- // player userIds; -1 means "broadcast to everyone".++ SetRateLimit: function (name, perSecond) { rateLimits[String(name).toLowerCase()] = perSecond | 0; },++++ // ---- send: server -> client ----Send: function (target) {if (!writeBuf) return;-- if (!isServer) { OV.warn("net.Send is server-only; use SendToServer on the client"); return; }++ if (!isServer) { warn("net.Send is server-only; use SendToServer on the client"); writeBuf = null; return; }var ids = [];if (target == null) { ids = [-1]; }-- else if (Array.isArray(target)) { ids = target.map(function (p) { return p && p.userId ? p.userId() : (p | 0); }); }-- else { ids = [target && target.userId ? target.userId() : (target | 0)]; }++ else if (Array.isArray(target)) { ids = target.map(userIdOf); }++ else { ids = [userIdOf(target)]; }var name = writeBuf.name, payload = serialize();-- if (OV.netEmit) OV.netEmit(ids.join(","), name, payload);writeBuf = null;++ if (payload != null) emitChunked(ids.join(","), name, payload);},Broadcast: function () {if (!writeBuf) return;-- if (!isServer) { OV.warn("net.Broadcast is server-only"); return; }++ if (!isServer) { warn("net.Broadcast is server-only"); writeBuf = null; return; }var name = writeBuf.name, payload = serialize();-- if (OV.netEmit) OV.netEmit("-1", name, payload);writeBuf = null;++ if (payload != null) emitChunked("-1", name, payload);},++ SendOmit: function (omit) {++ if (!writeBuf) return;++ if (!isServer) { warn("net.SendOmit is server-only"); writeBuf = null; return; }++ var omitIds = {};++ (Array.isArray(omit) ? omit : [omit]).forEach(function (p) { omitIds[userIdOf(p)] = true; });++ var all = (OV && OV.players ? OV.players() : []) || [];++ var ids = [];++ for (var i = 0; i < all.length; i++) {++ var uid = userIdOf(all[i]);++ if (!omitIds[uid]) ids.push(uid);++ }++ var name = writeBuf.name, payload = serialize();++ writeBuf = null;++ if (payload != null && ids.length) emitChunked(ids.join(","), name, payload);++ },++ // PVS/PAS need engine visibility data; until the native bridge exposes it++ // these behave as Broadcast (documented deviation).++ SendPVS: function (pos) { return net.Broadcast(); },++ SendPAS: function (pos) { return net.Broadcast(); },-- // client -> server++ // ---- send: client -> server ----SendToServer: function () {if (!writeBuf) return;-- var payload = serialize();-- if (OV.netSendToServer) OV.netSendToServer(writeBuf.name, payload);-- else OV.warn("net.SendToServer unavailable (no client bridge)");++ if (isServer) { warn("net.SendToServer is client-only"); writeBuf = null; return; }++ var name = writeBuf.name, payload = serialize();writeBuf = null;++ if (payload != null) sendToServerChunked(name, payload);},-- // Called from the C++ "OVNetReceive" hook. ply is the sending player object-- // for client->server messages, or null (server->client / server console).++ // ---- inbound dispatch (fired via the OVNetReceive hook) ----_dispatch: function (name, payloadB64, ply) {-- var fn = receivers[String(name)];++ name = String(name);++ if (name === "__ovchunk") {++ var whole = onChunk(String(payloadB64), ply);++ if (!whole) return;++ name = whole.name;++ payloadB64 = whole.payload;++ }++ var key = name.toLowerCase();++ var fn = receivers[key];if (!fn) return;++ if (!rateOk(key, ply)) { warn("rate limit exceeded for '" + name + "'"); return; }var decoded;try { decoded = JSON.parse(b64decode(payloadB64)); }-- catch (e) { OV.error("net: bad payload for " + name + ": " + (e && e.message)); return; }++ catch (e) { error("bad payload for " + name + ": " + (e && e.message)); return; }readBuf = { fields: (decoded && decoded.f) || [], cursor: 0 };-- try { fn(readBuf.fields.length, ply || null); }-- catch (e) { OV.error("net.Receive('" + name + "') threw: " + (e && e.message)); }++ var lenBits = String(payloadB64).length * 8;++ try { fn(lenBits, isServer ? (ply || null) : null); }++ catch (e) { error("net.Receive('" + name + "') threw: " + (e && e.stack ? e.stack : e.message)); }readBuf = null;-- }++ },++++ Receivers: receivers};globalThis.net = net;-- // util.AddNetworkString parity (names also travel inline, so this is advisory).-- globalThis.util = globalThis.util || {};-- if (!globalThis.util.AddNetworkString) {-- globalThis.util.AddNetworkString = function (name) { networkStrings[String(name)] = true; return name; };-- }---- // Bridge C++ inbound -> JS. C++ fires "OVNetReceive" with (name, payloadB64, plyUserId).++ // Bridge inbound -> JS. The bridge fires "OVNetReceive" with (name, payloadB64, ply).if (globalThis.hook && typeof hook.Add === "function") {hook.Add("OVNetReceive", "OpenVibeNetDispatch", function (name, payloadB64, ply) {net._dispatch(name, payloadB64, ply);++ return undefined;});}nnew file mode 100644@@@ -0,0 +1,221 @@++// OpenVibe Player class — GMod Player semantics over the native player bridge.++// https://wiki.facepunch.com/gmod/Player++//++// Player extends Entity. Native player handles (from the C++ bridge or the++// Node runtime) are wrapped once via Player.fromNative and cached by UserID;++// gamemode.call wraps hook arguments automatically, so gamemode code always++// sees framework Player objects. The legacy lowercase API (ply.chat(),++// ply.setTeam(), ...) is kept for existing gamemode code.++(function () {++ if (globalThis.Player && globalThis.Player.__openvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++ var Entity = globalThis.Entity;++++ var byUserId = Object.create(null); // userId -> Player++++ function Player(userId, entIndex) {++ Entity.call(this, entIndex || (100000 + userId));++ this.__ovPlayer = true;++ this._userId = userId | 0;++ this._native = null; // native bridge handle (lowercase methods)++ this._r.class = "player";++ this._r.health = 100;++ this._r.maxHealth = 100;++ this._r.team = 0;++ this._r.frags = 0;++ this._r.deaths = 0;++ this._r.alive = true;++ this._r.spawned = true;++ this._r.playerName = "";++ this._r.steamId = "";++ }++ Player.__openvibe = true;++ Player.prototype = Object.create(Entity.prototype);++ Player.prototype.constructor = Player;++ var P = Player.prototype;++++ function nat(self, method, args) {++ var h = self._native;++ if (h && typeof h[method] === "function") {++ try { return h[method].apply(h, args || []); } catch (e) { OV && OV.warn && OV.warn("player." + method + ": " + (e && e.message)); }++ }++ return undefined;++ }++++ // ---- identity ----++ P.IsPlayer = function () { return true; };++ P.UserID = function () { return this._userId; };++ P.Nick = function () { var v = nat(this, "name"); return v !== undefined ? v : (this._r.playerName || "Player" + this._userId); };++ P.Name = P.Nick;++ P.SteamID = function () { var v = nat(this, "steamId"); return v !== undefined ? v : this._r.steamId; };++ P.EntIndex = function () { var v = nat(this, "entIndex"); return v !== undefined ? v : this._key; };++ P.entIndex = P.EntIndex;++ P.IsBot = function () { return /^BOT/.test(this.SteamID() || ""); };++ P.IsValid = function () { return !this._r.removed && byUserId[this._userId] === this; };++ P.isValid = P.IsValid;++++ // ---- team / score ----++ P.Team = function () { var v = nat(this, "team"); return v !== undefined ? v : this._r.team; };++ P.SetTeam = function (t) { this._r.team = t | 0; nat(this, "setTeam", [t | 0]); };++ P.Frags = function () { return this._r.frags; };++ P.AddFrags = function (n) { this._r.frags += n | 0; };++ P.Deaths = function () { return this._r.deaths; };++ P.AddDeaths = function (n) { this._r.deaths += n | 0; };++++ // ---- life ----++ P.Health = function () { var v = nat(this, "health"); return v !== undefined ? v : this._r.health; };++ P.SetHealth = function (v) { this._r.health = v | 0; nat(this, "setHealth", [v | 0]); };++ P.Alive = function () { return this._r.alive && this.Health() > 0; };++ P.Kill = function () {++ if (!isServer) return;++ if (nat(this, "kill") === undefined) {++ // no native kill — emulate for the logical backend++ this._r.alive = false;++ this._r.health = 0;++ if (globalThis.hook) { try { hook.Run("PlayerDeath", this, globalThis.NULL, globalThis.NULL); } catch {} }++ }++ };++ P.KillSilent = P.Kill;++ P.Spawn = function () {++ this._r.alive = true;++ if (this._r.health <= 0) this._r.health = 100;++ nat(this, "respawn");++ };++ P.Armor = function () { return this._r.armor | 0; };++ P.SetArmor = function (v) { this._r.armor = v | 0; nat(this, "setArmor", [v | 0]); };++++ // ---- movement / state ----++ P.Freeze = function (b) { this._r.frozen = !!b; nat(this, "freeze", [!!b]); };++ P.IsFrozen = function () { return !!this._r.frozen; };++ P.GetPos = function () { var v = nat(this, "getPos"); return v || Entity.prototype.GetPos.call(this); };++ P.SetPos = function (v) { Entity.prototype.SetPos.call(this, v); nat(this, "setPos", [v.x, v.y, v.z]); };++++ // ---- chat / console ----++ P.ChatPrint = function (msg) { nat(this, "chat", [String(msg)]); };++ P.PrintMessage = function (type, msg) { this.ChatPrint(msg); };++ P.ConCommand = function (cmd) { nat(this, "runCommand", [String(cmd)]); };++ P.SendNet = function (name, fn) { /* convenience: net.Start(name); fn(); net.Send(this) */ };++++ // ---- weapons (native support pending; degrade gracefully) ----++ P.Give = function (cls) { nat(this, "give", [String(cls)]); return globalThis.NULL; };++ P.StripWeapons = function () { nat(this, "stripWeapons"); };++++ // ---- legacy lowercase API (existing gamemodes) ----++ P.userId = P.UserID;++ P.steamId = P.SteamID;++ P.name = P.Nick;++ P.health = P.Health;++ P.setHealth = P.SetHealth;++ P.team = P.Team;++ P.setTeam = P.SetTeam;++ P.chat = P.ChatPrint;++ P.runCommand = P.ConCommand;++++ // ---- wrapping / registry ----++ Player.fromNative = function (h) {++ if (!h) return null;++ if (h.__ovPlayer) return h;++ var uid = -1;++ try { uid = typeof h.userId === "function" ? h.userId() | 0 : -1; } catch {}++ if (uid < 0) return h;++ var ply = byUserId[uid];++ if (!ply) {++ var entIndex = 0;++ try { entIndex = typeof h.entIndex === "function" ? h.entIndex() | 0 : 0; } catch {}++ ply = new Player(uid, entIndex > 0 ? entIndex : undefined);++ byUserId[uid] = ply;++ Entity._register(ply);++ }++ ply._native = h;++ try { if (typeof h.name === "function") ply._r.playerName = h.name(); } catch {}++ try { if (typeof h.steamId === "function") ply._r.steamId = h.steamId() || ply._r.steamId; } catch {}++ return ply;++ };++++ Player.getOrCreate = function (userId, name) {++ var ply = byUserId[userId | 0];++ if (!ply) {++ ply = new Player(userId | 0);++ ply._r.playerName = name || "";++ byUserId[userId | 0] = ply;++ Entity._register(ply);++ }++ return ply;++ };++++ Player.drop = function (userId) {++ var ply = byUserId[userId | 0];++ if (ply) {++ Entity._unregister(ply);++ delete byUserId[userId | 0];++ }++ };++++ var playerLib = {++ __openvibe: true,++ GetAll: function () {++ // Prefer the live native list (covers players the wrapper hasn't seen)++ if (OV && OV.players) {++ var out = [];++ (OV.players() || []).forEach(function (h) {++ var p = Player.fromNative(h);++ if (p) out.push(p);++ });++ if (out.length) return out;++ }++ var cached = [];++ for (var k in byUserId) cached.push(byUserId[k]);++ return cached;++ },++ GetByUserID: function (uid) {++ var p = byUserId[uid | 0];++ if (p) return p;++ if (OV && OV.playerByUserId) {++ var h = OV.playerByUserId(uid | 0);++ if (h) return Player.fromNative(h);++ }++ return null;++ },++ GetBySteamID: function (sid) {++ sid = String(sid);++ return playerLib.GetAll().find(function (p) { return p.SteamID() === sid; }) || null;++ },++ GetCount: function () { return playerLib.GetAll().length; },++ Iterator: function () {++ var all = playerLib.GetAll(), i = 0;++ return function () { return i < all.length ? [i, all[i++]] : null; };++ }++ };++++ // ---- client realm: LocalPlayer ----++ var localPlayer = null;++ globalThis.LocalPlayer = function () {++ if (isServer) return globalThis.NULL;++ if (!localPlayer && OV && OV.localPlayer) {++ var h = OV.localPlayer();++ if (h) localPlayer = Player.fromNative(h);++ }++ return localPlayer || globalThis.NULL;++ };++ Player._setLocal = function (p) { localPlayer = p; };++++ // Cleanup on disconnect (server realm).++ if (globalThis.hook && typeof hook.Add === "function" && isServer) {++ hook.Add("PlayerDisconnected", "OpenVibePlayerRegistry", function (ply) {++ if (ply && typeof ply.UserID === "function") {++ // Defer drop so later hooks this tick still see the player.++ var uid = ply.UserID();++ if (globalThis.timer && timer.simple) timer.simple(0, function () { Player.drop(uid); });++ else Player.drop(uid);++ }++ return undefined;++ });++ }++++ globalThis.Player = Player;++ globalThis.player = playerLib;++++ if (OV && OV.log) OV.log("player library ready");++})();nnew file mode 100644@@@ -0,0 +1,26 @@++// OpenVibe realm globals — GMod States parity (SERVER / CLIENT).++// https://wiki.facepunch.com/gmod/States++//++// The menu realm (MENU) lives in the HTML GUI, not in this runtime.++(function () {++ var OV = globalThis.OV;++ var isServer = OV && typeof OV.isServer === "function" ? !!OV.isServer() : true;++++ globalThis.SERVER = isServer;++ globalThis.CLIENT = !isServer;++ globalThis.MENU = false;++++ // GMod-style validity check that also handles null/undefined and objects++ // exposing IsValid()/isValid().++ globalThis.IsValid = function (obj) {++ if (obj == null) return false;++ if (typeof obj.IsValid === "function") return !!obj.IsValid();++ if (typeof obj.isValid === "function") return !!obj.isValid();++ return true;++ };++++ // CurTime parity — engine time via the bridge.++ if (!globalThis.CurTime) {++ globalThis.CurTime = function () { return OV && OV.time ? OV.time() : 0; };++ }++})();nnew file mode 100644@@@ -0,0 +1,50 @@++// OpenVibe team library — GMod team semantics.++// https://wiki.facepunch.com/gmod/team++(function () {++ if (globalThis.team && globalThis.team.__openvibe) return;++++ var teams = Object.create(null); // id -> {name, color, score, joinable}++++ function players() {++ return globalThis.player && player.GetAll ? player.GetAll() : (globalThis.OV && OV.players ? OV.players() : []);++ }++ function teamOf(p) {++ if (!p) return 0;++ if (typeof p.Team === "function") return p.Team() | 0;++ if (typeof p.team === "function") return p.team() | 0;++ return 0;++ }++++ globalThis.team = {++ __openvibe: true,++++ SetUp: function (id, name, color, joinable) {++ id = id | 0;++ teams[id] = {++ name: String(name),++ color: color || (globalThis.Color ? Color(255, 255, 255) : { r: 255, g: 255, b: 255, a: 255 }),++ score: teams[id] ? teams[id].score : 0,++ joinable: joinable !== false++ };++ return id;++ },++ Valid: function (id) { return !!teams[id | 0]; },++ GetName: function (id) { var t = teams[id | 0]; return t ? t.name : "Unassigned"; },++ GetColor: function (id) { var t = teams[id | 0]; return t ? t.color : { r: 255, g: 255, b: 255, a: 255 }; },++ GetScore: function (id) { var t = teams[id | 0]; return t ? t.score : 0; },++ SetScore: function (id, score) { var t = teams[id | 0]; if (t) t.score = score | 0; },++ AddScore: function (id, amount) { var t = teams[id | 0]; if (t) t.score += amount | 0; },++ GetPlayers: function (id) {++ id = id | 0;++ return players().filter(function (p) { return teamOf(p) === id; });++ },++ NumPlayers: function (id) { return this.GetPlayers(id).length; },++ GetAllTeams: function () {++ var out = {};++ for (var id in teams) out[id] = teams[id];++ return out;++ }++ };++++ if (globalThis.OV && OV.log) OV.log("team library ready");++})();@@@ -66,6 +66,29 @@tick,list() {return Object.keys(timers).sort();++ },++++ // GMod-style aliases (https://wiki.facepunch.com/gmod/timer)++ Create(id, delay, reps, fn) {++ return create(id, delay, reps, fn);++ },++ Simple(delay, fn) {++ return create(`simple_${nextAutoId++}`, delay, 1, fn);++ },++ Remove(id) {++ return remove(id);++ },++ Exists(id) {++ return !!timers[String(id || "")];++ },++ Adjust(id, delay, reps, fn) {++ const item = timers[String(id || "")];++ if (!item) return false;++ if (delay !== undefined && delay !== null) item.delay = Math.max(0, Number(delay) || 0);++ if (reps !== undefined && reps !== null) item.reps = Number(reps) || 0;++ if (typeof fn === "function") item.fn = fn;++ item.next = now() + item.delay;++ return true;}};nnew file mode 100644@@@ -0,0 +1,67 @@++// OpenVibe util library — network string pooling + helpers.++// https://wiki.facepunch.com/gmod/util.AddNetworkString++(function () {++ if (globalThis.util && globalThis.util.__openvibe) return;++++ var OV = globalThis.OV;++ var isServer = OV && OV.isServer ? !!OV.isServer() : true;++++ // Pooled network strings: name -> id. GMod's table has 4095 usable slots.++ var MAX_STRINGS = 4095;++ var names = Object.create(null);++ var ids = [null]; // id 0 reserved (NULL)++++ function addNetworkString(name) {++ name = String(name);++ if (names[name] !== undefined) return names[name];++ if (!isServer) {++ // Clients cannot pool. GMod silently only lets the server pool; we track++ // locally so client Start() of a known name still works after sync.++ }++ if (ids.length > MAX_STRINGS) {++ if (OV && OV.warn) OV.warn("util.AddNetworkString: networkstring table is full, can't add " + name);++ return 0;++ }++ var id = ids.length;++ ids.push(name);++ names[name] = id;++ return id;++ }++++ function networkStringToID(name) {++ var id = names[String(name)];++ return id === undefined ? 0 : id;++ }++++ function networkIDToString(id) {++ return ids[id | 0] || undefined;++ }++++ // FNV-1a 32-bit — stable content hash usable in both QuickJS and Node,++ // used by the file-sync manifest (not cryptographic).++ function fnv1a(str) {++ str = String(str);++ var h = 0x811c9dc5;++ for (var i = 0; i < str.length; i++) {++ h ^= str.charCodeAt(i) & 0xff;++ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;++ }++ return ("0000000" + h.toString(16)).slice(-8);++ }++++ var util = globalThis.util || {};++ util.__openvibe = true;++ util.AddNetworkString = addNetworkString;++ util.NetworkStringToID = networkStringToID;++ util.NetworkIDToString = networkIDToString;++ util.CRC = fnv1a;++ util.TableToJSON = function (t, pretty) { return JSON.stringify(t, null, pretty ? 2 : 0); };++ util.JSONToTable = function (s) { try { return JSON.parse(String(s)); } catch { return null; } };++ util.IsValid = function (o) { return globalThis.IsValid ? IsValid(o) : o != null; };++++ globalThis.util = util;++++ // Shared pooled-name access for net.js.++ util.__netNames = names;++ util.__netIds = ids;++})();nnew file mode 100644@@@ -0,0 +1,6 @@++// ov_bouncy_crate — client realm.++include("entities/ov_bouncy_crate/shared.js");++++ENT.Initialize = function () {++ // Clientside representation; the engine draws the model itself.++};nnew file mode 100644@@@ -0,0 +1,38 @@++// ov_bouncy_crate — server realm.++AddCSJSFile("entities/ov_bouncy_crate/shared.js");++AddCSJSFile("entities/ov_bouncy_crate/cl_init.js");++include("entities/ov_bouncy_crate/shared.js");++++ENT.Initialize = function () {++ this.SetModel("models/props_junk/wood_crate001a.mdl");++ this.PhysicsInit(SOLID_VPHYSICS);++ this.SetMoveType(MOVETYPE_VPHYSICS);++ this.SetSolid(SOLID_VPHYSICS);++ this.SetHealth(50);++ this.SetMaxHealth(50);++ this.SetBounces(0);++ const phys = this.GetPhysicsObject();++ if (IsValid(phys)) phys.Wake();++ this.NextThink(CurTime() + 2);++};++++ENT.Think = function () {++ // Hop every 2 seconds.++ this.SetBounces(this.GetBounces() + 1);++ const phys = this.GetPhysicsObject();++ if (IsValid(phys)) phys.ApplyForceCenter({ x: 0, y: 0, z: 4000 });++ this.NextThink(CurTime() + 2);++};++++ENT.OnTakeDamage = function (dmg) {++ this.SetHealth(this.Health() - dmg.GetDamage());++ if (this.Health() <= 0) this.Remove();++};++++ENT.OnRemove = function () {++ if (globalThis.OV && OV.log) OV.log("bouncy crate removed after " + this.GetBounces() + " bounces");++};++++ENT.Use = function (activator) {++ if (activator && activator.ChatPrint) activator.ChatPrint("Crate has bounced " + this.GetBounces() + " times.");++};nnew file mode 100644@@@ -0,0 +1,12 @@++// ov_bouncy_crate — demo scripted entity (folder form, GMod SENT layout).++// shared.js runs in both realms; the loader provides the ENT global.++ENT.Type = "anim";++ENT.Base = "base_gmodentity";++ENT.PrintName = "Bouncy Crate";++ENT.Author = "OpenVibe.Games";++ENT.Spawnable = true;++ENT.Category = "OpenVibe";++++ENT.SetupDataTables = function () {++ this.NetworkVar("Int", 0, "Bounces");++};@@@ -1 +1,35 @@--console.log("Base client realm loaded");++// OpenVibe base gamemode — client realm.++// Receives the HUD state snapshots the server broadcasts and forwards them to++// the HTML GUI overlay (window.OV.onHudState) through the menu bridge.++(function () {++ const GM = {++ mode: "base",++ name: "OpenVibe Base (client)",++++ Initialize() {++ OV.log("Base client Initialize");++ }++ };++++ function pushHudToGui(state) {++ if (!OV || typeof OV.menuJS !== "function") return;++ try {++ const json = JSON.stringify(state).replace(/[\\"']/g, function (c) { return "\\" + c; });++ // ov_menu_js -> panel RunJavascript. The GUI ignores it when no HUD view.++ OV.menuJS('window.OV&&OV.onHudState&&OV.onHudState(JSON.parse("' + json + '"))');++ } catch (e) { /* GUI push is best-effort */ }++ }++++ if (globalThis.net && net.__openvibe) {++ net.Receive(globalThis.OVBase ? OVBase.HUD_NET : "OV_HudState", function () {++ const state = net.ReadTable();++ if (globalThis.OVBase) OVBase.hudState = state;++ hook.Run("OVHudState", state);++ pushHudToGui(state);++ });++ }++++ // Client realm mirrors the base gamemode so submode client files can derive.++ gamemode.setBase(GM);++ gamemode.set(GM, { base: true });++})();@@@ -17,11 +17,20 @@// Round tracking (do not override)_roundState: "idle", // "idle" | "countdown" | "active" | "ended"_roundNumber: 0,++ _roundEndsAt: 0,Initialize() {OV.log("Base Initialize fired");},++ // Define teams via team.SetUp here (submodes override).++ CreateTeams() {++ if (globalThis.team) {++ team.SetUp(0, "Unassigned", Color(200, 200, 200));++ team.SetUp(1, "Spectator", Color(150, 150, 150), false);++ }++ },++MapInitialize(mapName) {OV.log(`Base MapInitialize: ${mapName}`);this.scheduleRoundStart();@@@ -29,9 +38,14 @@PlayerInitialSpawn(ply) {ply.chat("Welcome to OpenVibe: Source.");++ this.broadcastHudState();++ },++++ PlayerSpawn(ply) {++ hook.Run("PlayerLoadout", ply);},-- PlayerSpawn(_ply) {},++ PlayerLoadout(_ply) {},PlayerDeath(_victim, _attacker) {},@@@ -47,9 +61,11 @@startRound() {this._roundNumber += 1;this._roundState = "active";++ this._roundEndsAt = OV.time() + this.roundDuration;OV.log(`[OV Round] RoundStart round=${this._roundNumber}`);hook.Run("RoundStart", this._roundNumber);OV.broadcast(`Round ${this._roundNumber} — begin!`);++ this.broadcastHudState();const roundDuration = this.roundDuration;const roundNumber = this._roundNumber;@@@ -65,13 +81,54 @@endRound(reason) {if (this._roundState !== "active") return;this._roundState = "ended";++ this._roundEndsAt = 0;OV.log(`[OV Round] RoundEnd round=${this._roundNumber} reason=${reason}`);hook.Run("RoundEnd", this._roundNumber, reason || "time");OV.broadcast(`Round ${this._roundNumber} ended (${reason || "time"}).`);++ this.broadcastHudState();timer.remove(`ov_round_end_${this._roundNumber}`);this.scheduleRoundStart();},++ // ---- HUD state replication (drives the HTML HUD overlay) ----++ // Broadcasts a compact snapshot over the net library; the client realm++ // forwards it into the GUI (window.OV.onHudState).++ buildHudState() {++ const teams = [];++ if (globalThis.team && team.GetAllTeams) {++ const all = team.GetAllTeams();++ for (const id in all) {++ teams.push({++ id: id | 0, name: all[id].name, color: all[id].color,++ score: all[id].score, players: team.NumPlayers(id | 0)++ });++ }++ }++ return {++ mode: this.mode, name: this.name,++ state: this._roundState, round: this._roundNumber,++ timeLeft: this._roundEndsAt ? Math.max(0, Math.round(this._roundEndsAt - OV.time())) : 0,++ players: (OV.players ? OV.players() : []).length,++ teams++ };++ },++++ broadcastHudState() {++ if (!globalThis.net || !net.__openvibe) return;++ try {++ net.Start(globalThis.OVBase ? OVBase.HUD_NET : "OV_HudState");++ net.WriteTable(this.buildHudState());++ net.Broadcast();++ } catch (e) { /* net transport not up yet */ }++ },++++ _startHudTicker() {++ const self = this;++ if (globalThis.timer && timer.create) {++ timer.create("ov_hud_state", 3, 0, function () { self.broadcastHudState(); });++ }++ },++// Begin countdown then start round.scheduleRoundStart() {const players = OV.players ? OV.players() : [];@@@ -100,6 +157,17 @@gamemode.setBase(GM);gamemode.set(GM, { base: true });++ // Base setup that must run for EVERY mode, even ones that override++ // GM.Initialize without calling up the chain (hook.Add'd hooks always run++ // before the GM method and cannot be shadowed by submodes).++ hook.Add("Initialize", "OpenVibeBaseSetup", function () {++ if (globalThis.util && util.AddNetworkString) util.AddNetworkString(globalThis.OVBase ? OVBase.HUD_NET : "OV_HudState");++ hook.Run("CreateTeams");++ const gm = gamemode.get();++ if (gm && typeof gm._startHudTicker === "function") gm._startHudTicker();++ return undefined;++ });++// Load addons now that all core systems (require, hook, command, timer,// gamemode) are available. Runs in both realms; Addon.loadAll() picks the// right per-realm entry files via OV.isServer(). The active gamemode file@@@ -1,22 +1,13 @@--export const GM = {-- mode: "base",-- name: "OpenVibe Base",---- Initialize() {-- console.log("Base gamemode initialized");-- },---- PlayerInitialSpawn(ply) {-- ply.chat("Welcome to OpenVibe: Source");-- },---- PlayerSpawn(_ply) {},---- PlayerDeath(_victim, _attacker, _inflictor) {},---- PlayerSay(_ply, text) {-- return undefined;-- }--};----gamemode.set(GM);++// OpenVibe base gamemode — shared realm (both server and client).++// Identity + shared constants live here; realm entries build on them.++(function () {++ globalThis.OVBase = {++ TEAM_UNASSIGNED: 0,++ TEAM_SPECTATOR: 1,++ // Modes define their own playable teams as 2/3.++ HUD_NET: "OV_HudState",++ hudState: null // last known round/HUD snapshot (client keeps it fresh)++ };++++ if (globalThis.OV && OV.log) OV.log("base shared.js loaded (realm=" + (globalThis.SERVER ? "server" : "client") + ")");++})();nnew file mode 100644@@@ -0,0 +1,22 @@++// OpenVibe Deathrun — client realm.++(function () {++ const TEAM_RUNNERS = 2, TEAM_ACTIVATORS = 3;++++ const GM = {++ mode: "deathrun",++ name: "OpenVibe Deathrun (client)",++ Initialize() { OV.log("Deathrun client Initialize"); }++ };++++ if (globalThis.net && net.__openvibe) {++ net.Receive("OV_DR_Role", function () {++ const teamId = net.ReadInt();++ const role = teamId === TEAM_ACTIVATORS ? "ACTIVATOR" : "RUNNER";++ OV.log("[DR] You are the " + role);++ hook.Run("OVRoleAssigned", role, teamId, 0);++ if (OV.menuJS) OV.menuJS('window.OV&&OV.onRole&&OV.onRole("' + role + '",' + (teamId | 0) + ',0)');++ });++ }++++ gamemode.set(GM);++})();nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "id": "deathrun",++ "name": "OpenVibe Deathrun",++ "base": "base",++ "category": "pvp",++ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },++ "settings": [++ { "name": "ov_dr_round_duration", "text": "Round duration (s)", "type": "Numeric", "default": 240 }++ ]++}@@@ -29,6 +29,13 @@} else {p.setTeam(TEAM_RUNNERS);}++ if (globalThis.net && net.__openvibe) {++ try {++ net.Start("OV_DR_Role");++ net.WriteInt(p.team());++ net.Send(p);++ } catch (e) { /* transport not up */ }++ }});runnersAlive = players.filter((p) => p.team() === TEAM_RUNNERS).length;@@@ -54,6 +61,14 @@Initialize() {OV.log("Deathrun Initialize fired");registerCommands();++ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_DR_Role");++ },++++ CreateTeams() {++ if (!globalThis.team) return;++ team.SetUp(0, "Unassigned", Color(200, 200, 200));++ team.SetUp(TEAM_RUNNERS, "Runners", Color(80, 160, 240));++ team.SetUp(TEAM_ACTIVATORS, "Activator", Color(240, 80, 80));},MapInitialize(mapName) {@@@ -68,6 +83,7 @@startRound() {this._roundNumber += 1;this._roundState = "active";++ this._roundEndsAt = OV.time() + ROUND_DURATION;activatorId = null;assignTeams();@@@ -92,6 +108,7 @@endRound(reason) {if (this._roundState !== "active") return;this._roundState = "ended";++ this._roundEndsAt = 0;timer.remove(`ov_dr_round_end_${this._roundNumber}`);let msg;nnew file mode 100644@@@ -0,0 +1,20 @@++// OpenVibe Fort Wars — client realm.++(function () {++ const GM = {++ mode: "fortwars",++ name: "OpenVibe Fort Wars (client)",++ Initialize() { OV.log("Fort Wars client Initialize"); }++ };++++ if (globalThis.net && net.__openvibe) {++ net.Receive("OV_FW_Phase", function () {++ const phase = net.ReadString();++ const seconds = net.ReadInt();++ OV.log("[FW] Phase: " + phase + " (" + seconds + "s)");++ hook.Run("OVPhaseChanged", phase, seconds);++ if (OV.menuJS) OV.menuJS('window.OV&&OV.onPhase&&OV.onPhase("' + phase + '",' + (seconds | 0) + ')');++ });++ }++++ gamemode.set(GM);++})();nnew file mode 100644@@@ -0,0 +1,11 @@++{++ "id": "fortwars",++ "name": "OpenVibe Fort Wars",++ "base": "base",++ "category": "pvp",++ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },++ "settings": [++ { "name": "ov_fw_build_duration", "text": "Build phase (s)", "type": "Numeric", "default": 60 },++ { "name": "ov_fw_fight_duration", "text": "Fight phase (s)", "type": "Numeric", "default": 180 }++ ]++}@@@ -27,6 +27,16 @@OV.broadcast(`Fort Wars: Red (${redAlive}) vs Blue (${blueAlive})`);}++ function broadcastPhase(name, seconds) {++ if (!globalThis.net || !net.__openvibe) return;++ try {++ net.Start("OV_FW_Phase");++ net.WriteString(name);++ net.WriteInt(seconds | 0);++ net.Broadcast();++ } catch (e) { /* transport not up */ }++ }++function registerCommands() {if (!globalThis.command) return;@@@ -58,6 +68,14 @@Initialize() {OV.log("Fort Wars Initialize fired");registerCommands();++ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_FW_Phase");++ },++++ CreateTeams() {++ if (!globalThis.team) return;++ team.SetUp(0, "Unassigned", Color(200, 200, 200));++ team.SetUp(TEAM_RED, "Red", Color(235, 75, 60));++ team.SetUp(TEAM_BLUE, "Blue", Color(60, 130, 235));},MapInitialize(mapName) {@@@ -72,7 +90,9 @@startRound() {this._roundNumber += 1;this._roundState = "active";++ this._roundEndsAt = OV.time() + BUILD_DURATION + FIGHT_DURATION;phase = "build";++ broadcastPhase("build", BUILD_DURATION);assignTeams();@@@ -91,6 +111,7 @@OV.broadcast("BUILD PHASE OVER! FIGHT!");OV.serverCommand("ov_fortwars_build_enabled 0");hook.Run("FW_FightPhaseStart", roundNum);++ broadcastPhase("fight", FIGHT_DURATION);});// End of round timer@@@ -110,6 +131,7 @@endRound(reason) {if (this._roundState !== "active") return;this._roundState = "ended";++ this._roundEndsAt = 0;timer.remove(`ov_fw_round_end_${this._roundNumber}`);phase = "build";OV.serverCommand("ov_fortwars_build_enabled 1");nnew file mode 100644@@@ -0,0 +1,9 @@++// OpenVibe Hub — client realm.++(function () {++ const GM = {++ mode: "hub",++ name: "OpenVibe Hub (client)",++ Initialize() { OV.log("Hub client Initialize"); }++ };++ gamemode.set(GM);++})();nnew file mode 100644@@@ -0,0 +1,25 @@++// OpenVibe Prop Hunt — client realm.++(function () {++ const TEAM_PROPS = 2, TEAM_HUNTERS = 3;++++ const GM = {++ mode: "prophunt",++ name: "OpenVibe Prop Hunt (client)",++ Initialize() { OV.log("Prop Hunt client Initialize"); }++ };++++ if (globalThis.net && net.__openvibe) {++ net.Receive("OV_PH_Role", function () {++ const teamId = net.ReadInt();++ const lockSecs = net.ReadInt();++ const role = teamId === TEAM_HUNTERS ? "HUNTER" : "PROP";++ OV.log("[PH] You are a " + role + (teamId === TEAM_HUNTERS ? " (released in " + lockSecs + "s)" : " — hide!"));++ hook.Run("OVRoleAssigned", role, teamId, lockSecs);++ if (OV.menuJS) {++ OV.menuJS('window.OV&&OV.onRole&&OV.onRole("' + role + '",' + (teamId | 0) + ',' + (lockSecs | 0) + ')');++ }++ });++ }++++ gamemode.set(GM);++})();nnew file mode 100644@@@ -0,0 +1,11 @@++{++ "id": "prophunt",++ "name": "OpenVibe Prop Hunt",++ "base": "base",++ "category": "pvp",++ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },++ "settings": [++ { "name": "ov_ph_round_duration", "text": "Round duration (s)", "type": "Numeric", "default": 180 },++ { "name": "ov_ph_hunter_lock", "text": "Hunter lock time (s)", "type": "Numeric", "default": 20 }++ ]++}@@@ -32,7 +32,17 @@const shuffled = players.slice().sort(() => Math.random() - 0.5);shuffled.forEach(function (p, i) {-- p.setTeam(i < hunterCount ? TEAM_HUNTERS : TEAM_PROPS);++ const t = i < hunterCount ? TEAM_HUNTERS : TEAM_PROPS;++ p.setTeam(t);++ // Private role notification over the net library (client HUD shows it).++ if (globalThis.net && net.__openvibe) {++ try {++ net.Start("OV_PH_Role");++ net.WriteInt(t);++ net.WriteInt(HUNTER_LOCK_DURATION);++ net.Send(p);++ } catch (e) { /* transport not up */ }++ }});countAlive();@@@ -66,6 +76,14 @@Initialize() {OV.log("Prop Hunt Initialize fired");registerCommands();++ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_PH_Role");++ },++++ CreateTeams() {++ if (!globalThis.team) return;++ team.SetUp(0, "Unassigned", Color(200, 200, 200));++ team.SetUp(TEAM_PROPS, "Props", Color(80, 200, 120));++ team.SetUp(TEAM_HUNTERS, "Hunters", Color(220, 90, 60));},MapInitialize(mapName) {@@@ -80,6 +98,7 @@startRound() {this._roundNumber += 1;this._roundState = "active";++ this._roundEndsAt = OV.time() + ROUND_DURATION;locked = true;assignTeams();@@@ -112,6 +131,7 @@endRound(reason) {if (this._roundState !== "active") return;this._roundState = "ended";++ this._roundEndsAt = 0;locked = false;timer.remove(`ov_ph_round_end_${this._roundNumber}`);@@@ -1,24 +1,41 @@--// OpenVibe Sandbox — CLIENT realm (runs inside client.dll QuickJS now).--OV.log("sandbox client.js running — realm server?=" + OV.isServer());++// OpenVibe Sandbox — client realm.++(function () {++ OV.log("sandbox client.js running — realm server?=" + OV.isServer());--// npm module usage on the CLIENT--try {-- var leftpad = require("ov-leftpad");-- OV.log("client require('ov-leftpad')('9',4,'0')=" + leftpad("9", 4, "0"));--} catch (e) { OV.error("client require failed: " + e.message); }++ // npm module usage on the CLIENT++ try {++ var leftpad = require("ov-leftpad");++ OV.log("client require('ov-leftpad')('9',4,'0')=" + leftpad("9", 4, "0"));++ } catch (e) { OV.error("client require failed: " + e.message); }--// server -> client net: receive a welcome the server pushes on spawn--if (globalThis.net) {-- net.Receive("OV_Sandbox_Welcome", function (len) {-- var msg = net.ReadString();-- OV.log("CLIENT received OV_Sandbox_Welcome: " + msg);-- });-- OV.log("client net.Receive('OV_Sandbox_Welcome') ready");--}++ const GM = {++ mode: "sandbox",++ name: "OpenVibe Sandbox (client)",--// client Initialize hook--if (globalThis.hook) {-- hook.Add("Initialize", "SandboxClientInit", function () {-- OV.log("client Initialize hook fired");-- });--}++ Initialize() {++ OV.log("Sandbox client Initialize");++ }++ };++++ // server -> client net: receive a welcome the server pushes on spawn++ if (globalThis.net) {++ net.Receive("OV_Sandbox_Welcome", function (len) {++ var msg = net.ReadString();++ OV.log("CLIENT received OV_Sandbox_Welcome: " + msg);++ });++ OV.log("client net.Receive('OV_Sandbox_Welcome') ready");++ }++++ // Q-menu spawn request: the GUI (or a keybind) triggers this; the server++ // validates the prop id — we never trust the client value there.++ if (globalThis.concommand) {++ concommand.Add("ov_qmenu_spawn", function (_ply, _cmd, args) {++ if (!globalThis.net) return;++ net.Start("OV_Sandbox_Spawn");++ net.WriteString(String(args[0] || "crate"));++ net.SendToServer();++ }, null, "Request a sandbox prop spawn from the server");++ }++++ gamemode.set(GM);++})();@@@ -92,15 +92,45 @@return false;});});++++ // Entity-system spawn: !ent <class> — demonstrates ents.Create end to end++ // (scripted entities like ov_bouncy_crate, or allowlisted engine classes++ // when the native entity bridge is compiled in).++ command.add("ent", "Spawn an entity: !ent ov_bouncy_crate", function ({ ply, args, reply }) {++ const cls = String(args[0] || "ov_bouncy_crate");++ const ent = ents.Create(cls);++ if (!IsValid(ent)) {++ reply(ply, `Unknown entity class: ${cls}`);++ return false;++ }++ if (ply) {++ const p = ply.GetPos ? ply.GetPos() : { x: 0, y: 0, z: 0 };++ const fwd = ply.GetForward ? ply.GetForward() : { x: 1, y: 0, z: 0 };++ ent.SetPos({ x: p.x + fwd.x * 96, y: p.y + fwd.y * 96, z: p.z + 32 });++ }++ ent.Spawn();++ ent.Activate();++ reply(ply, `Spawned ${cls} (entindex ${ent.EntIndex()})`);++ return false;++ });++++ command.add("ents", "Count live entities", function ({ ply, reply }) {++ reply(ply, `Entities: ${ents.GetCount()} (${ents.FindByClass("ov_*").length} scripted ov_*)`);++ return false;++ });}// Networked Q-menu spawn: the client sends the chosen prop via the net// library (ov_net OV_Sandbox_Spawn <payload>). spawnProp already validates// the prop id against the allowlist, so we never trust the client's value.-- function registerNet() {-- if (!globalThis.net) return;++ // Pool at file scope (GMod convention: before any net.Start can run).++ if (globalThis.util && util.AddNetworkString) {util.AddNetworkString("OV_Sandbox_Spawn");util.AddNetworkString("OV_Sandbox_Welcome");++ }++++ function registerNet() {++ if (!globalThis.net) return;net.Receive("OV_Sandbox_Spawn", function (len, ply) {var id = net.ReadString();if (!ply) { OV.warn("OV_Sandbox_Spawn with no player; ignoring"); return; }nnew file mode 100644@@@ -0,0 +1,23 @@++// OpenVibe Traitor Town — client realm.++(function () {++ const TEAM_TRAITOR = 3;++++ const GM = {++ mode: "traitortown",++ name: "OpenVibe Traitor Town (client)",++ Initialize() { OV.log("Traitor Town client Initialize"); }++ };++++ if (globalThis.net && net.__openvibe) {++ net.Receive("OV_TTT_Role", function () {++ const teamId = net.ReadInt();++ const role = teamId === TEAM_TRAITOR ? "TRAITOR" : "INNOCENT";++ // Role stays local to this client — never broadcast.++ OV.log("[TTT] Your role: " + role);++ hook.Run("OVRoleAssigned", role, teamId, 0);++ if (OV.menuJS) OV.menuJS('window.OV&&OV.onRole&&OV.onRole("' + role + '",' + (teamId | 0) + ',0)');++ });++ }++++ gamemode.set(GM);++})();nnew file mode 100644@@@ -0,0 +1,11 @@++{++ "id": "traitortown",++ "name": "OpenVibe Traitor Town",++ "base": "base",++ "category": "pvp",++ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },++ "settings": [++ { "name": "ov_ttt_round_duration", "text": "Round duration (s)", "type": "Numeric", "default": 300 },++ { "name": "ov_ttt_traitor_ratio", "text": "Players per traitor", "type": "Numeric", "default": 4 }++ ]++}@@@ -32,9 +32,17 @@traitorsAlive = shuffled.filter((p) => p.team() === TEAM_TRAITOR).length;rolesAssigned = true;-- // Tell traitors who they are (privately via chat)-- shuffled.filter((p) => p.team() === TEAM_TRAITOR).forEach(function (p) {-- p.chat("[TRAITOR] You are a traitor. Eliminate all innocents.");++ // Tell everyone their role privately: chat for traitors + a net message++ // per player so the client HUD can render it.++ shuffled.forEach(function (p) {++ if (p.team() === TEAM_TRAITOR) p.chat("[TRAITOR] You are a traitor. Eliminate all innocents.");++ if (globalThis.net && net.__openvibe) {++ try {++ net.Start("OV_TTT_Role");++ net.WriteInt(p.team());++ net.Send(p);++ } catch (e) { /* transport not up */ }++ }});OV.broadcast(`Round ${gamemode.get()._roundNumber} — ${players.length} players. Find the traitor(s)!`);@@@ -69,6 +77,14 @@Initialize() {OV.log("Traitor Town Initialize fired");registerCommands();++ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_TTT_Role");++ },++++ CreateTeams() {++ if (!globalThis.team) return;++ team.SetUp(0, "Unassigned", Color(200, 200, 200));++ team.SetUp(TEAM_INNOCENT, "Innocents", Color(60, 180, 90));++ team.SetUp(TEAM_TRAITOR, "Traitors", Color(200, 40, 40));},MapInitialize(mapName) {@@@ -83,6 +99,7 @@startRound() {this._roundNumber += 1;this._roundState = "active";++ this._roundEndsAt = OV.time() + ROUND_DURATION;rolesAssigned = false;innocentsAlive = 0;traitorsAlive = 0;@@@ -105,6 +122,7 @@endRound(reason) {if (this._roundState !== "active") return;this._roundState = "ended";++ this._roundEndsAt = 0;rolesAssigned = false;timer.remove(`ov_ttt_round_end_${this._roundNumber}`);ddeleted file mode 100644@@@ -1,6 +0,0 @@--module.exports = function leftpad(str, len, ch) {-- str = String(str);-- ch = ch || " ";-- while (str.length < len) str = ch + str;-- return str;--};ddeleted file mode 100644@@@ -1 +0,0 @@--{ "name": "ov-leftpad", "version": "1.0.0", "main": "index.js" }nnew file mode 100644@@@ -0,0 +1,42 @@++{++ "name": "openvibe-game-scripts",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-game-scripts",++ "dependencies": {++ "is-odd": "^3.0.1",++ "ov-leftpad": "file:vendor/ov-leftpad"++ }++ },++ "node_modules/is-number": {++ "version": "6.0.0",++ "resolved": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz",++ "integrity": "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/is-odd": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",++ "integrity": "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==",++ "license": "MIT",++ "dependencies": {++ "is-number": "^6.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/ov-leftpad": {++ "resolved": "vendor/ov-leftpad",++ "link": true++ },++ "vendor/ov-leftpad": {++ "version": "1.0.0"++ }++ }++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "name": "openvibe-game-scripts",++ "private": true,++ "description": "npm root for the OpenVibe GModJS runtime (js/node_modules is require()-able from gamemodes, addons, and entities in both backends).",++ "dependencies": {++ "is-odd": "^3.0.1",++ "ov-leftpad": "file:vendor/ov-leftpad"++ }++}nnew file mode 100644@@@ -0,0 +1,6 @@++module.exports = function leftpad(str, len, ch) {++ str = String(str);++ ch = ch || " ";++ while (str.length < len) str = ch + str;++ return str;++};nnew file mode 100644@@@ -0,0 +1 @@++{ "name": "ov-leftpad", "version": "1.0.0", "main": "index.js" }nnew file mode 100644@@@ -0,0 +1,11 @@++// Shadow material: stock HL2 charger glow sprite that the dedicated server's++// mounted content doesn't expose to precache. Mirrors hl2/materials/sprites/++// orangelight1_noz.vmt using a base texture that ships with HL2MP content.++"UnlitGeneric"++{++ "$basetexture" "sprites/glow01"++ "$spriteorientation" "vp_parallel"++ "$spriterendermode" "9"++ "$ignorez" "1"++ "$nofog" "1"++}@@@ -1,47 +1,84 @@# OpenVibe Unified UI Shell--The Electron launcher and the in-game Source/VGUI embedded browser now load the same web shell.++The Electron launcher, the in-game Source/VGUI embedded browser, and the++backend all serve/load the SAME app: the repo-root `client/` directory++(`client/index.html` + `client/ui-sync.js`).## Hosts- Electron loads `http://127.0.0.1:5173/client/?electron=1&shell=electron#portal`++ (window.OV = preload IPC bridge; the app object is `window.OVApp`)- Source/VGUI loads `http://127.0.0.1:5173/client/?embedded=1&shell=source#portal`++ (window.OV = the app object, talks via `openvibe://` navigations)++- The backend serves the identical files at `http://127.0.0.1:3000/client/`++- HUD overlay: append `&hud=1` (or `#hud`) — translucent, pointer-events none--Both are served by `tools/serve-client-ui.mjs` from the `launcher/` folder.++Port 5173 is `tools/serve-client-ui.mjs`, which now serves the repo `client/`++directory for `/client/*` (plus `/health` and `launcher/assets` at `/assets/*`).++++## Routes++++`portal, servers, leaderboard, inventory, shop, settings, options, console, hud`++++- `#console` — merged SSE log view (server runtime 127.0.0.1:41997/logs,++ client runtime 127.0.0.1:41996/logs), JS eval per realm (`POST /eval`),++ engine commands via `openvibe://cmd?c=` in-game or `POST 41997/exec`++ elsewhere; `]cmd` forces engine, `npm …` goes to `POST /npm`.++- `#options` — game convars (allowlisted, `openvibe://convar?name=&value=`,++ current values pushed via `window.OV.onConvars`), UI prefs++ (`localStorage["openvibe.options.v1"]`, synced across hosts), account++ fields, and the active gamemode's `manifest.json settings[]` from++ `GET 41997/state`. Account auth stays on `#settings`.++- `#hud` / `?hud=1` — round banner, team + timer, health, gamemode name;++ driven by `window.OV.onHudState(obj)` with `GET 41996/41997 /state` polling++ as fallback.## In-game commands--- `ov_ui`--- `ov_main_menu`--- `ov_menu_main`--- `ov_menu_servers`--- `ov_menu_leaderboard`--- `ov_menu_inventory`--- `ov_menu_shop`--- `ov_menu_settings`--- `ov_menu_close`--- `ov_menu_reload`++- `ov_ui`, `ov_main_menu`, `ov_menu_main`++- `ov_menu_servers`, `ov_menu_leaderboard`, `ov_menu_inventory`,++ `ov_menu_shop`, `ov_menu_settings`, `ov_menu_options`, `ov_menu_console`++- `ov_menu_close`, `ov_menu_reload`## Bridge URLsThe in-game HTML panel accepts allowlisted `openvibe://` actions:--- `openvibe://join?mode=hub`--- `openvibe://join?mode=prophunt`--- `openvibe://join?mode=deathrun`--- `openvibe://join?mode=fortwars`--- `openvibe://join?mode=traitortown`--- `openvibe://close`--- `openvibe://reload`++- `openvibe://join?mode=<hub|prophunt|deathrun|fortwars|traitortown>`++- `openvibe://cmd?c=<urlencoded engine command>` (allowlisted in C++)++- `openvibe://convar?name=<name>&value=<value>` (allowlisted convars)++- `openvibe://convar_get?names=<a,b,c>` → C++ replies via `window.OV.onConvars`++- `openvibe://close`, `openvibe://reload`, `openvibe://ready`++++Electron receives equivalent actions through the `window.OV` preload bridge++(`launcher/preload.js`); the `ui:set-route` allowlist in `launcher/main.js`++includes `options` and `console`.++++C++ push entry points on the page (in-game): `window.OV.onConsoleLine(lines)`,++`window.OV.onConvars(obj)`, `window.OV.onHudState(obj)`.--Electron receives equivalent actions through `window.OV` in `preload.js`.++## Keyboard++++- `F1` portal, `F2` servers++- `` ` `` or `F10` toggles the console route++- `Esc` → `openvibe://close` (in-game)## Sync model--`launcher/openvibe-ui-sync.js` keeps route state in:++`client/ui-sync.js` keeps route + UI-pref state in:- `location.hash`- `localStorage["openvibe.ui.state.v1"]`- `BroadcastChannel("openvibe-ui-sync-v1")`--That gives Electron and in-game the same route/state contract without duplicating UI code.++That gives Electron and in-game the same route/state contract without++duplicating UI code. The `hud` route is never applied remotely.++++## Test++++`node tools/smoke-ui.mjs` — serves the app on an ephemeral port and asserts++all route markers, the sync layer, `/health`, and the backend's on-disk++`client/index.html` mapping.++++`launcher/index.html` + `renderer.js` remain only as the offline fallback++Electron loads if the UI server cannot start.@@@ -368,7 +368,7 @@ ipcMain.handle('launcher:show', async () => {ipcMain.on('open-url', (_e, url) => shell.openExternal(url));ipcMain.on('ui:set-route', (_e, route) => {-- const safe = ['portal', 'servers', 'leaderboard', 'inventory', 'shop', 'settings'].includes(route) ? route : 'portal';++ const safe = ['portal', 'servers', 'leaderboard', 'inventory', 'shop', 'settings', 'options', 'console'].includes(route) ? route : 'portal';mainWindow?.webContents.send('ui:set-route', safe);});@@@ -8,6 +8,7 @@// panel. This is a thin, crash-proof bridge.#include "openvibe/ov_ipc.h"#include "usermessages.h"++#include "c_baseplayer.h"#include <stdlib.h>#include <string.h>@@@ -25,6 +26,7 @@ static ConVar ov_client_mode(static COpenVibeIPC g_ClientIPC;static bool g_bClientBridgeStarted = false;++static bool g_bSentLocalPlayer = false;// ---- tiny JSON helpers (build + extract) ----static void OVJSON_AppendEscaped( char *dst, int dstSize, const char *src )@@@ -115,11 +117,38 @@ void __MsgFunc_OVNet( bf_read &msg )g_ClientIPC.SendLine( out );}++// ---- outbound: local player identity -> runtime ----++// Sent once per level, as soon as the local player entity is valid (it isn't++// yet at LevelInitPostEntity, so OpenVibeJS_Client_Think retries).++static void OVClient_TrySendLocalPlayer()++{++ if ( g_bSentLocalPlayer || !g_ClientIPC.IsConnected() ) return;++++ C_BasePlayer *pLocal = C_BasePlayer::GetLocalPlayer();++ if ( !pLocal ) return;++++ player_info_t pi;++ if ( !engine->GetPlayerInfo( pLocal->entindex(), &pi ) ) return;++++ char out[512];++ Q_snprintf( out, sizeof( out ), "{\"t\":\"local_player\",\"userId\":%d,\"name\":\"", pi.userID );++ OVJSON_AppendEscaped( out, sizeof( out ), pi.name );++ Q_strncat( out, "\",\"steamId\":\"", sizeof( out ), COPY_ALL_CHARACTERS );++ OVJSON_AppendEscaped( out, sizeof( out ), pi.guid );++ char tail[48];++ Q_snprintf( tail, sizeof( tail ), "\",\"entIndex\":%d}", pLocal->entindex() );++ Q_strncat( out, tail, sizeof( out ), COPY_ALL_CHARACTERS );++++ g_ClientIPC.SendLine( out );++ g_bSentLocalPlayer = true;++}++// ---- lifecycle ----void OpenVibeJS_Client_Init(){if ( g_bClientBridgeStarted ) return;g_bClientBridgeStarted = true;++ g_bSentLocalPlayer = false;if ( !ov_client_js_enabled.GetBool() ){Msg( "[OV JS/client] bridge disabled by ov_client_js_enabled=0\n" );@@@ -145,6 +174,19 @@ void OpenVibeJS_Client_Shutdown()void OpenVibeJS_Client_Think(){g_ClientIPC.Poll();++++ if ( !g_ClientIPC.IsConnected() )++ return;++++ OVClient_TrySendLocalPlayer();++++ // 10Hz think to the client runtime (mirrors the server bridge).++ static float s_flNextThink = 0.0f;++ if ( gpGlobals && gpGlobals->curtime >= s_flNextThink )++ {++ s_flNextThink = gpGlobals->curtime + 0.1f;++ g_ClientIPC.SendLine( "{\"t\":\"think\"}" );++ }}static void OV_ClientJSStatus_f()@@@ -165,6 +207,72 @@ static void OV_ClientJSReconnect_f()static ConCommand ov_client_js_reconnect( "ov_client_js_reconnect", OV_ClientJSReconnect_f,"Reconnect the OpenVibe client Node runtime bridge.", FCVAR_CLIENTDLL );++// ---- js_run_cl / js_openscript_cl — gated by the replicated sv_allowcsjs ----++// sv_allowcsjs (GMod's sv_allowcslua) is registered server-side with++// FCVAR_REPLICATED; read it through the cvar system so the server's value++// applies. Unknown (not connected / not registered) counts as allowed —++// the dev default is 1.++static bool OVClient_CSJSAllowed()++{++ const ConVar *pAllow = g_pCVar ? g_pCVar->FindVar( "sv_allowcsjs" ) : NULL;++ if ( pAllow && !pAllow->GetBool() )++ {++ Warning( "[OV JS/client] blocked: server has sv_allowcsjs 0\n" );++ return false;++ }++ return true;++}++++static void OV_JSRunCl_f( const CCommand &args )++{++ if ( args.ArgC() < 2 )++ {++ Msg( "Usage: js_run_cl <code>\n" );++ return;++ }++++ if ( !OVClient_CSJSAllowed() )++ return;++++ if ( !g_ClientIPC.IsConnected() )++ {++ Warning( "[OV JS/client] runtime not connected; js_run_cl ignored\n" );++ return;++ }++++ char out[4096] = "{\"t\":\"eval\",\"code\":\"";++ OVJSON_AppendEscaped( out, sizeof( out ), args.ArgS() );++ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ClientIPC.SendLine( out );++}++static ConCommand js_run_cl( "js_run_cl", OV_JSRunCl_f,++ "Run JavaScript in the client realm: js_run_cl <code>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );++++static void OV_JSOpenScriptCl_f( const CCommand &args )++{++ if ( args.ArgC() < 2 )++ {++ Msg( "Usage: js_openscript_cl <path relative to js/>\n" );++ return;++ }++++ if ( !OVClient_CSJSAllowed() )++ return;++++ if ( !g_ClientIPC.IsConnected() )++ {++ Warning( "[OV JS/client] runtime not connected; js_openscript_cl ignored\n" );++ return;++ }++++ char out[1024] = "{\"t\":\"openscript\",\"path\":\"";++ OVJSON_AppendEscaped( out, sizeof( out ), args[1] );++ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ClientIPC.SendLine( out );++}++static ConCommand js_openscript_cl( "js_openscript_cl", OV_JSOpenScriptCl_f,++ "Run a script file in the client realm: js_openscript_cl <path relative to js/>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );++// Drives the bridge: hook OVNet, connect on level enter, poll each frame.class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame{@@@ -25,6 +25,11 @@ using namespace vgui;// in this file, so Linux/GCC needs an explicit forward declaration.void OpenVibe_OnHTMLReady();++// HandleBridgeURL() / OnThink() also use these before their definitions below.++static void OV_SanitiseJSString( char *pszOut, size_t nOut, const char *pszIn );++static void OV_InstallConsoleTap();++static void OV_ConsoleTapThink();++static ConVar ov_menu_url("ov_menu_url",@@@ -79,6 +84,85 @@ static bool OV_IsSafeMode( const char *pszMode )!Q_stricmp( pszMode, "traitortown" ) );}++// openvibe://cmd allowlist: the first token decides. ov_* commands (which++// includes ov_npm and all OpenVibe dev commands) plus the GModJS script++// commands and a few stock engine commands the GUI console needs.++static bool OV_IsAllowedBridgeCommand( const char *pszCommand )++{++ if ( !pszCommand || !pszCommand[0] )++ return false;++++ // No multi-command or newline smuggling through the bridge.++ if ( Q_strstr( pszCommand, ";" ) || Q_strstr( pszCommand, "\n" ) || Q_strstr( pszCommand, "\r" ) )++ return false;++++ char szToken[64];++ int i = 0;++ while ( pszCommand[i] && pszCommand[i] != ' ' && i < (int)sizeof( szToken ) - 1 )++ {++ szToken[i] = pszCommand[i];++ ++i;++ }++ szToken[i] = '\0';++++ if ( !Q_strnicmp( szToken, "ov_", 3 ) )++ return true;++++ static const char *s_AllowedBridgeCommands[] =++ {++ "js_run",++ "js_run_cl",++ "js_openscript",++ "js_openscript_cl",++ "ov_npm",++ "say",++ "connect",++ "disconnect",++ "retry",++ "status",++ };++++ for ( int j = 0; j < ARRAYSIZE( s_AllowedBridgeCommands ); ++j )++ {++ if ( !Q_stricmp( szToken, s_AllowedBridgeCommands[j] ) )++ return true;++ }++++ return false;++}++++// openvibe://convar[_get] allowlist: OpenVibe client settings plus the stock++// game/video/audio convars the options route exposes.++static const char *s_OVAllowedBridgeConvars[] =++{++ "ov_menu_url",++ "ov_menu_auto_open",++ "ov_menu_allow_remote",++ "ov_api_url",++ "ov_client_js_enabled",++ "ov_js_backend",++ "volume",++ "snd_musicvolume",++ "mat_monitorgamma",++ "fov_desired",++ "sensitivity",++ "cl_ragdoll_collide",++};++++static bool OV_IsAllowedBridgeConvar( const char *pszName )++{++ if ( !pszName || !pszName[0] )++ return false;++++ for ( int i = 0; i < ARRAYSIZE( s_OVAllowedBridgeConvars ); ++i )++ {++ if ( !Q_stricmp( pszName, s_OVAllowedBridgeConvars[i] ) )++ return true;++ }++++ return false;++}++static void OV_URLDecodeInPlace( char *pszValue ){if ( !pszValue )@@@ -241,6 +325,12 @@ public:MakeFullScreen();}++ void OnThink() OVERRIDE++ {++ BaseClass::OnThink();++ OV_ConsoleTapThink();++ }++bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE{if ( OV_URLStartsWith( pszURL, "openvibe://" ) )@@@ -316,9 +406,94 @@ private:return;}++ if ( OV_URLStartsWith( pszURL, "openvibe://cmd" ) )++ {++ // GUI console -> engine command, allowlisted by first token.++ char szCmd[1024];++ if ( !OV_ReadQueryValue( pszURL, "c", szCmd, sizeof( szCmd ) ) )++ return;++++ if ( !OV_IsAllowedBridgeCommand( szCmd ) )++ {++ Warning( "[OV HTML] blocked bridge command: %s\n", szCmd );++ return;++ }++++ engine->ClientCmd_Unrestricted( VarArgs( "%s\n", szCmd ) );++ return;++ }++++ // NOTE: convar_get must be checked before the "openvibe://convar" prefix.++ if ( OV_URLStartsWith( pszURL, "openvibe://convar_get" ) )++ {++ char szNames[512];++ if ( OV_ReadQueryValue( pszURL, "names", szNames, sizeof( szNames ) ) )++ PushConvarsToPage( szNames );++ return;++ }++++ if ( OV_URLStartsWith( pszURL, "openvibe://convar" ) )++ {++ char szName[64];++ char szValue[256];++ if ( !OV_ReadQueryValue( pszURL, "name", szName, sizeof( szName ) ) ||++ !OV_ReadQueryValue( pszURL, "value", szValue, sizeof( szValue ) ) )++ return;++++ if ( !OV_IsAllowedBridgeConvar( szName ) )++ {++ Warning( "[OV HTML] blocked convar set: %s\n", szName );++ return;++ }++++ ConVar *pVar = g_pCVar ? g_pCVar->FindVar( szName ) : NULL;++ if ( pVar )++ pVar->SetValue( szValue );++ else++ Warning( "[OV HTML] convar not found: %s\n", szName );++ return;++ }++Warning( "[OV HTML] Ignored bridge URL: %s\n", pszURL ? pszURL : "<null>" );}++ // openvibe://convar_get — push allowlisted convar values back into the page++ // as window.OV.onConvars({name: "value", ...}).++ void PushConvarsToPage( char *pszNamesCsv )++ {++ char szScript[4096];++ Q_strncpy( szScript,++ "if(typeof window.OV==='object'&&typeof window.OV.onConvars==='function')"++ "{window.OV.onConvars({", sizeof( szScript ) );++++ bool bFirst = true;++ for ( char *pszName = strtok( pszNamesCsv, "," ); pszName; pszName = strtok( NULL, "," ) )++ {++ while ( *pszName == ' ' )++ ++pszName;++++ // Allowlisted names are literal matches of s_OVAllowedBridgeConvars,++ // so they are safe to embed; values still go through the sanitiser.++ if ( !*pszName || !OV_IsAllowedBridgeConvar( pszName ) )++ continue;++++ ConVar *pVar = g_pCVar ? g_pCVar->FindVar( pszName ) : NULL;++ if ( !pVar )++ continue;++++ char szValue[256];++ OV_SanitiseJSString( szValue, sizeof( szValue ), pVar->GetString() );++++ char szPair[384];++ Q_snprintf( szPair, sizeof( szPair ), "%s'%s':'%s'", bFirst ? "" : ",", pszName, szValue );++ Q_strncat( szScript, szPair, sizeof( szScript ), COPY_ALL_CHARACTERS );++ bFirst = false;++ }++++ Q_strncat( szScript, "});}", sizeof( szScript ), COPY_ALL_CHARACTERS );++ RunJS( szScript );++ }++HTML *m_pHTML;};@@@ -327,11 +502,121 @@ static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;static COpenVibeHTMLPanel *OV_GetHTMLMenu(){if ( !s_pOpenVibeMenu )++ {s_pOpenVibeMenu = new COpenVibeHTMLPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );++ OV_InstallConsoleTap();++ }return s_pOpenVibeMenu;}++// OPENVIBE_CONSOLE_SPEW_TAP_BEGIN++//++// Mirrors engine console output into the HTML GUI console route. The chained++// tier0 spew hook records EVERY line into a ring buffer from client-mode init++// (so the GUI console gets full history, replacing the stock Source console);++// the panel's OnThink drains the backlog into window.OV.onConsoleLine([...])++// while visible, at most every 0.15s and 24 lines per flush. When the console++// runs hot the oldest unsent lines fall out of the ring (never blocks).++++#define OV_CONSOLE_RING_LINES 400++#define OV_CONSOLE_FLUSH_MAX 24++++static SpewOutputFunc_t s_PrevSpewFunc = NULL;++static bool s_bConsoleTapInstalled = false;++static bool s_bInConsoleTap = false; // reentrancy: a Msg inside RunJS must not recurse++static char s_ConsoleRing[OV_CONSOLE_RING_LINES][480];++static int64 s_nRingWritten = 0; // total lines ever written++static int64 s_nRingSent = 0; // total lines ever flushed to the panel++static float s_flNextConsoleFlush = 0.0f;++++static SpewRetval_t OV_ConsoleTapSpewFunc( SpewType_t spewType, const tchar *pMsg )++{++ // Always forward to the previous spew func first.++ SpewRetval_t ret = s_PrevSpewFunc ? s_PrevSpewFunc( spewType, pMsg ) : SPEW_CONTINUE;++++ if ( !s_bInConsoleTap && pMsg && pMsg[0] )++ {++ s_bInConsoleTap = true;++++ char *pszLine = s_ConsoleRing[s_nRingWritten % OV_CONSOLE_RING_LINES];++ int nWritten = 0;++ // Tag warnings/errors so the GUI can color them.++ if ( spewType == SPEW_WARNING || spewType == SPEW_ASSERT || spewType == SPEW_ERROR )++ {++ const char *pszTag = ( spewType == SPEW_WARNING ) ? "[warning] " : "[error] ";++ for ( const char *t = pszTag; *t && nWritten < (int)sizeof( s_ConsoleRing[0] ) - 1; ++t )++ pszLine[nWritten++] = *t;++ }++ for ( const char *p = pMsg; *p && nWritten < (int)sizeof( s_ConsoleRing[0] ) - 1; ++p )++ pszLine[nWritten++] = ( *p == '\n' || *p == '\r' ) ? ' ' : *p;++ while ( nWritten > 0 && pszLine[nWritten - 1] == ' ' )++ --nWritten;++ pszLine[nWritten] = '\0';++++ if ( nWritten > 0 )++ ++s_nRingWritten;++++ s_bInConsoleTap = false;++ }++++ return ret;++}++++static void OV_InstallConsoleTap()++{++ if ( s_bConsoleTapInstalled )++ return;++++ s_bConsoleTapInstalled = true;++ s_PrevSpewFunc = GetSpewOutputFunc();++ SpewOutputFunc( OV_ConsoleTapSpewFunc );++}++++// Called from COpenVibeHTMLPanel::OnThink (i.e. only while the panel is visible).++static void OV_ConsoleTapThink()++{++ if ( !s_pOpenVibeMenu || s_nRingSent >= s_nRingWritten )++ return;++++ const float flNow = Plat_FloatTime();++ if ( flNow < s_flNextConsoleFlush )++ return;++ s_flNextConsoleFlush = flNow + 0.15f;++++ // If the writer lapped us, skip the lines the ring no longer holds.++ if ( s_nRingWritten - s_nRingSent > OV_CONSOLE_RING_LINES )++ s_nRingSent = s_nRingWritten - OV_CONSOLE_RING_LINES;++++ char szScript[16384];++ Q_strncpy( szScript,++ "if(typeof window.OV==='object'&&typeof window.OV.onConsoleLine==='function')"++ "{window.OV.onConsoleLine([", sizeof( szScript ) );++++ int nBatch = 0;++ while ( s_nRingSent < s_nRingWritten && nBatch < OV_CONSOLE_FLUSH_MAX )++ {++ char szLine[480];++ OV_SanitiseJSString( szLine, sizeof( szLine ), s_ConsoleRing[s_nRingSent % OV_CONSOLE_RING_LINES] );++ ++s_nRingSent;++++ if ( nBatch > 0 )++ Q_strncat( szScript, ",", sizeof( szScript ), COPY_ALL_CHARACTERS );++ Q_strncat( szScript, "'", sizeof( szScript ), COPY_ALL_CHARACTERS );++ Q_strncat( szScript, szLine, sizeof( szScript ), COPY_ALL_CHARACTERS );++ Q_strncat( szScript, "'", sizeof( szScript ), COPY_ALL_CHARACTERS );++ ++nBatch;++ }++++ Q_strncat( szScript, "]);}", sizeof( szScript ), COPY_ALL_CHARACTERS );++++ // RunJS can itself Msg(); the guard keeps that spew out of the batch.++ s_bInConsoleTap = true;++ s_pOpenVibeMenu->RunJS( szScript );++ s_bInConsoleTap = false;++}++// OPENVIBE_CONSOLE_SPEW_TAP_END++static void OV_OpenMenu_f( const CCommand &args ){const char *pszURL = args.ArgC() >= 2 ? args[1] : NULL;@@@ -358,6 +643,10 @@ static void OV_MenuJS_f( const CCommand &args )void OpenVibe_OnClientModeInit(){++ // Install the console tap immediately so the GUI console has full history++ // from process start, even before the panel is first opened.++ OV_InstallConsoleTap();++if ( ov_menu_auto_open.GetBool() )OV_GetHTMLMenu()->Open();}@@@ -422,6 +711,8 @@ static void OV_MenuLeaderboard_f( const CCommand &args ) { OV_OpenMenuRoute( "lestatic void OV_MenuInventory_f( const CCommand &args ) { OV_OpenMenuRoute( "inventory" ); }static void OV_MenuShop_f( const CCommand &args ) { OV_OpenMenuRoute( "shop" ); }static void OV_MenuSettings_f( const CCommand &args ) { OV_OpenMenuRoute( "settings" ); }++static void OV_MenuConsole_f( const CCommand &args ) { OV_OpenMenuRoute( "console" ); }++static void OV_MenuOptions_f( const CCommand &args ) { OV_OpenMenuRoute( "options" ); }static ConCommand ov_ui_cmd("ov_ui",@@@ -470,6 +761,37 @@ static ConCommand ov_menu_settings_route_cmd(OV_MenuSettings_f,"Open the OpenVibe settings route.",FCVAR_CLIENTDLL );++++static ConCommand ov_menu_console_cmd(++ "ov_menu_console",++ OV_MenuConsole_f,++ "Open the OpenVibe console route.",++ FCVAR_CLIENTDLL );++++static ConCommand ov_menu_options_cmd(++ "ov_menu_options",++ OV_MenuOptions_f,++ "Open the OpenVibe options route.",++ FCVAR_CLIENTDLL );++++// The OpenVibe console replaces the stock Source console: bind your console++// key to ov_console (cfg/openvibe_client_default.cfg does this) and it++// toggles the HTML console route.++static void OV_ConsoleToggle_f( const CCommand &args )++{++ if ( s_pOpenVibeMenu && s_pOpenVibeMenu->IsVisible() )++ {++ s_pOpenVibeMenu->CloseMenu();++ return;++ }++ OV_OpenMenuRoute( "console" );++}++++static ConCommand ov_console_cmd(++ "ov_console",++ OV_ConsoleToggle_f,++ "Toggle the OpenVibe HTML console (replacement for the stock console).",++ FCVAR_CLIENTDLL );// OPENVIBE_UNIFIED_UI_ROUTE_COMMANDS_END// OPENVIBE_STEAM_AUTH_BRIDGE_BEGIN@@@ -18,6 +18,14 @@ static ConVar ov_js_enabled(FCVAR_GAMEDLL,"Enable OpenVibe JavaScript runtime. Windows server CI uses a stub runtime." );++// GMod's sv_allowcslua equivalent: gates js_run_cl / js_openscript_cl on++// clients. Replicated so the client bridge can read it. Default 1 for dev.++static ConVar sv_allowcsjs(++ "sv_allowcsjs",++ "1",++ FCVAR_REPLICATED | FCVAR_NOTIFY,++ "Allow clients to run their own JavaScript (js_run_cl / js_openscript_cl)." );++void OpenVibeJS_ServerInit(){Msg("[OV JS] Windows server CI stub active; runtime disabled, mode=%s\n", ov_mode.GetString());@@@ -81,6 +89,42 @@ static ConCommand ov_js_cmd(FCVAR_GAMEDLL);++static void OV_JSRun_f(const CCommand &args)++{++ Msg("[OV JS] Windows server CI stub: js_run ignored\n");++}++++static ConCommand js_run(++ "js_run",++ OV_JSRun_f,++ "Run JavaScript in the server realm: js_run <code>.",++ FCVAR_GAMEDLL++);++++static void OV_JSOpenScript_f(const CCommand &args)++{++ Msg("[OV JS] Windows server CI stub: js_openscript ignored\n");++}++++static ConCommand js_openscript(++ "js_openscript",++ OV_JSOpenScript_f,++ "Run a script file in the server realm: js_openscript <path relative to js/>.",++ FCVAR_GAMEDLL++);++++static void OV_Npm_f(const CCommand &args)++{++ Warning("ov_npm requires ov_js_backend node\n");++}++++static ConCommand ov_npm(++ "ov_npm",++ OV_Npm_f,++ "Run npm in the mod js/ tree via the Node runtime: ov_npm <install|update|...> [args].",++ FCVAR_GAMEDLL++);++void OpenVibeJS_Server_RoundStart(int roundNumber){Msg("[OV JS] Windows server CI stub: RoundStart round=%d\n", roundNumber);@@@ -151,6 +195,12 @@ static ConVar ov_js_node_port("ov_js_node_port", "41999", FCVAR_GAMEDLL,"TCP port of the OpenVibe server Node.js runtime host." );++// GMod's sv_allowcslua equivalent: gates js_run_cl / js_openscript_cl on++// clients. Replicated so the client bridge can read it. Default 1 for dev.++static ConVar sv_allowcsjs(++ "sv_allowcsjs", "1", FCVAR_REPLICATED | FCVAR_NOTIFY,++ "Allow clients to run their own JavaScript (js_run_cl / js_openscript_cl)." );++static COpenVibeJSRuntime g_OVServerJS;static bool g_OVServerJSStarted = false;@@@ -218,6 +268,10 @@ static void OVServer_OnLine( const char *line )if ( p ) { engine->ClientCommand( p->edict(), "%s", cmd ); }}}++ else if ( !Q_strcmp( t, "eval-result" ) )++ {++ // js_run result echoed back by the runtime — already logged there.++ }else if ( !Q_strcmp( t, "net" ) ){// server -> client net: OVNet usermessage. ids: CSV of userIds, -1 = all.@@@ -252,6 +306,22 @@ static void OVServer_SendHello()g_ServerIPC.SendLine( hello );}++// Append a {"__player":true,"userId":N} marker (or null) to an outbound++// {"t":"event"} line — ov-runtime.js onGameMessage wraps these back into++// Player objects before firing the hook.++static void OVServer_AppendPlayerMarker( char *out, int cap, CHL2MP_Player *player )++{++ if ( !player )++ {++ Q_strncat( out, "null", cap, COPY_ALL_CHARACTERS );++ return;++ }++++ char marker[64];++ Q_snprintf( marker, sizeof( marker ), "{\"__player\":true,\"userId\":%d}", player->GetUserID() );++ Q_strncat( out, marker, cap, COPY_ALL_CHARACTERS );++}++static bool OpenVibeJS_IsRunning(){return ov_js_enabled.GetBool() && g_OVServerJS.Context() != nullptr;@@@ -355,6 +425,23 @@ void OpenVibeJS_Server_PlayerInitialSpawn( CHL2MP_Player *player ){OpenVibeJS_EnsureStarted();++ if ( OVServer_UseNode() )++ {++ if ( !player || !g_ServerIPC.IsConnected() )++ return;++ const char *netid = engine->GetPlayerNetworkIDString( player->edict() );++ char out[512];++ Q_snprintf( out, sizeof( out ), "{\"t\":\"player_connect\",\"userId\":%d,\"name\":\"", player->GetUserID() );++ OVJSONAppendEsc( out, sizeof( out ), player->GetPlayerName() );++ Q_strncat( out, "\",\"steamId\":\"", sizeof( out ), COPY_ALL_CHARACTERS );++ OVJSONAppendEsc( out, sizeof( out ), netid ? netid : "" );++ char tail[48];++ Q_snprintf( tail, sizeof( tail ), "\",\"entIndex\":%d}", player->entindex() );++ Q_strncat( out, tail, sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++ return;++ }++if ( !OpenVibeJS_IsRunning() || !player )return;@@@ -371,6 +458,17 @@ void OpenVibeJS_Server_PlayerSpawn( CHL2MP_Player *player ){OpenVibeJS_EnsureStarted();++ if ( OVServer_UseNode() )++ {++ if ( !player || !g_ServerIPC.IsConnected() )++ return;++ char out[256] = "{\"t\":\"event\",\"name\":\"PlayerSpawn\",\"args\":[";++ OVServer_AppendPlayerMarker( out, sizeof( out ), player );++ Q_strncat( out, "]}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++ return;++ }++if ( !OpenVibeJS_IsRunning() || !player )return;@@@ -387,6 +485,21 @@ void OpenVibeJS_Server_PlayerDeath( CHL2MP_Player *victim, CBaseEntity *attacker{OpenVibeJS_EnsureStarted();++ if ( OVServer_UseNode() )++ {++ if ( !victim || !g_ServerIPC.IsConnected() )++ return;++ CHL2MP_Player *attackerPlayer = ( attacker && attacker->IsPlayer() )++ ? ToHL2MPPlayer( static_cast<CBasePlayer *>( attacker ) ) : NULL;++ char out[256] = "{\"t\":\"event\",\"name\":\"PlayerDeath\",\"args\":[";++ OVServer_AppendPlayerMarker( out, sizeof( out ), victim );++ Q_strncat( out, ",", sizeof( out ), COPY_ALL_CHARACTERS );++ OVServer_AppendPlayerMarker( out, sizeof( out ), attackerPlayer );++ Q_strncat( out, "]}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++ return;++ }++if ( !OpenVibeJS_IsRunning() || !victim )return;@@@ -409,6 +522,16 @@ void OpenVibeJS_Server_PlayerDisconnected( CHL2MP_Player *player ){OpenVibeJS_EnsureStarted();++ if ( OVServer_UseNode() )++ {++ if ( !player || !g_ServerIPC.IsConnected() )++ return;++ char out[64];++ Q_snprintf( out, sizeof( out ), "{\"t\":\"player_disconnect\",\"userId\":%d}", player->GetUserID() );++ g_ServerIPC.SendLine( out );++ return;++ }++if ( !OpenVibeJS_IsRunning() || !player )return;@@@ -532,6 +655,164 @@ static ConCommand ov_js_cmd(FCVAR_GAMEDLL);++// ---------------------------------------------------------------------------++// GModJS script commands — js_run / js_openscript / ov_npm (server realm).++// Embedded backend evals in-process; node backend forwards over IPC.++// Results are echoed to the invoking client's console (the HTML GUI console++// mirrors the client console through the spew tap, so this is what makes++// js_run output visible in-game).++// ---------------------------------------------------------------------------++static void OV_JSEchoToCaller( const char *pszLine )++{++ if ( !pszLine || !pszLine[0] )++ return;++++ CBasePlayer *pPlayer = UTIL_GetCommandClient();++ if ( pPlayer )++ {++ char szMsg[512];++ Q_snprintf( szMsg, sizeof( szMsg ), "[OV JS] %s\n", pszLine );++ ClientPrint( pPlayer, HUD_PRINTCONSOLE, szMsg );++ }++}++++static void OV_JSRun_f( const CCommand &args )++{++ if ( args.ArgC() < 2 )++ {++ Msg( "Usage: js_run <code>\n" );++ return;++ }++++ OpenVibeJS_EnsureStarted();++++ if ( OVServer_UseNode() )++ {++ if ( !g_ServerIPC.IsConnected() )++ {++ Warning( "[OV JS] node runtime not connected; js_run ignored\n" );++ OV_JSEchoToCaller( "node runtime not connected; js_run ignored" );++ return;++ }++ char out[4096] = "{\"t\":\"eval\",\"code\":\"";++ OVJSONAppendEsc( out, sizeof( out ), args.ArgS() );++ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++ OV_JSEchoToCaller( "sent to node runtime (result in runtime log / GUI console)" );++ return;++ }++++ if ( !OpenVibeJS_IsRunning() )++ {++ Warning( "[OV JS] runtime is not running\n" );++ OV_JSEchoToCaller( "runtime is not running" );++ return;++ }++++ char szResult[480];++ g_OVServerJS.RunStringResult( args.ArgS(), szResult, sizeof( szResult ) );++ OV_JSEchoToCaller( szResult );++}++++static ConCommand js_run(++ "js_run",++ OV_JSRun_f,++ "Run JavaScript in the server realm: js_run <code>.",++ FCVAR_GAMEDLL++);++++static void OV_JSOpenScript_f( const CCommand &args )++{++ if ( args.ArgC() < 2 )++ {++ Msg( "Usage: js_openscript <path relative to js/>\n" );++ return;++ }++++ OpenVibeJS_EnsureStarted();++++ if ( OVServer_UseNode() )++ {++ if ( !g_ServerIPC.IsConnected() )++ {++ Warning( "[OV JS] node runtime not connected; js_openscript ignored\n" );++ OV_JSEchoToCaller( "node runtime not connected; js_openscript ignored" );++ return;++ }++ char out[1024] = "{\"t\":\"openscript\",\"path\":\"";++ OVJSONAppendEsc( out, sizeof( out ), args[1] );++ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++ OV_JSEchoToCaller( "sent to node runtime" );++ return;++ }++++ if ( !OpenVibeJS_IsRunning() )++ {++ Warning( "[OV JS] runtime is not running\n" );++ OV_JSEchoToCaller( "runtime is not running" );++ return;++ }++++ char code[1024] = "OVLoader.openScript(\"";++ OVJSONAppendEsc( code, sizeof( code ), args[1] );++ Q_strncat( code, "\")", sizeof( code ), COPY_ALL_CHARACTERS );++ char szResult[480];++ g_OVServerJS.RunStringResult( code, szResult, sizeof( szResult ) );++ char szEcho[512];++ Q_snprintf( szEcho, sizeof( szEcho ), "js_openscript %s -> %s", args[1], szResult );++ OV_JSEchoToCaller( szEcho );++}++++static ConCommand js_openscript(++ "js_openscript",++ OV_JSOpenScript_f,++ "Run a script file in the server realm: js_openscript <path relative to js/>.",++ FCVAR_GAMEDLL++);++++static void OV_Npm_f( const CCommand &args )++{++ if ( args.ArgC() < 2 )++ {++ Msg( "Usage: ov_npm <install|update|uninstall|ls> [args]\n" );++ return;++ }++++ OpenVibeJS_EnsureStarted();++++ if ( !OVServer_UseNode() )++ {++ Warning( "ov_npm requires ov_js_backend node\n" );++ return;++ }++++ if ( !g_ServerIPC.IsConnected() )++ {++ Warning( "[OV JS] node runtime not connected; ov_npm ignored\n" );++ return;++ }++++ char out[1024] = "{\"t\":\"npm\",\"args\":[";++ for ( int i = 1; i < args.ArgC(); ++i )++ {++ if ( i > 1 )++ Q_strncat( out, ",", sizeof( out ), COPY_ALL_CHARACTERS );++ Q_strncat( out, "\"", sizeof( out ), COPY_ALL_CHARACTERS );++ OVJSONAppendEsc( out, sizeof( out ), args[i] );++ Q_strncat( out, "\"", sizeof( out ), COPY_ALL_CHARACTERS );++ }++ Q_strncat( out, "]}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++}++++static ConCommand ov_npm(++ "ov_npm",++ OV_Npm_f,++ "Run npm in the mod js/ tree via the Node runtime: ov_npm <install|update|...> [args].",++ FCVAR_GAMEDLL++);++// ---------------------------------------------------------------------------// net library — client->server transport.// The client forwards net messages as: ov_net <name> <payloadBase64>@@@ -598,6 +879,17 @@ void OpenVibeJS_Server_RoundStart( int roundNumber ){OpenVibeJS_EnsureStarted();++ if ( OVServer_UseNode() )++ {++ if ( !g_ServerIPC.IsConnected() )++ return;++ char out[96];++ Q_snprintf( out, sizeof( out ), "{\"t\":\"event\",\"name\":\"RoundStart\",\"args\":[%d]}", roundNumber );++ g_ServerIPC.SendLine( out );++ Msg( "[OV JS] RoundStart forwarded round=%d\n", roundNumber );++ return;++ }++if ( !OpenVibeJS_IsRunning() )return;@@@ -614,6 +906,19 @@ void OpenVibeJS_Server_RoundEnd( int roundNumber, const char *reason ){OpenVibeJS_EnsureStarted();++ if ( OVServer_UseNode() )++ {++ if ( !g_ServerIPC.IsConnected() )++ return;++ char out[256];++ Q_snprintf( out, sizeof( out ), "{\"t\":\"event\",\"name\":\"RoundEnd\",\"args\":[%d,\"", roundNumber );++ OVJSONAppendEsc( out, sizeof( out ), reason ? reason : "time" );++ Q_strncat( out, "\"]}", sizeof( out ), COPY_ALL_CHARACTERS );++ g_ServerIPC.SendLine( out );++ Msg( "[OV JS] RoundEnd forwarded round=%d reason=%s\n", roundNumber, reason ? reason : "time" );++ return;++ }++if ( !OpenVibeJS_IsRunning() )return;@@@ -10,6 +10,9 @@ void OVJS_RegisterNativeBindings(JSContext *ctx, COpenVibeJSRuntime *runtime) {}#include "ov_js_bindings.h"#include "ov_js_runtime.h"#include "ov_js_player.h"++#ifdef GAME_DLL++#include "ov_js_entity.h"++#endif#include "hl2mp_player.h"#include "util.h"#include "filesystem.h"@@@ -348,6 +351,11 @@ static const JSCFunctionListEntry OVFuncs[] =JS_CFUNC_DEF("fileExists", 1, OVJS_fileExists),JS_CFUNC_DEF("listDir", 2, OVJS_listDir),JS_CFUNC_DEF("netEmit", 3, OVJS_netEmit),++#ifdef GAME_DLL++ // Entity natives (server realm only) — implemented in ov_js_entity.cpp.++ JS_CFUNC_DEF("entCreate", 1, OVJS_entCreate),++ JS_CFUNC_DEF("entCall", 3, OVJS_entCall),++#endif};void OVJS_RegisterNativeBindings(JSContext *ctx, COpenVibeJSRuntime *runtime)nnew file mode 100644@@@ -0,0 +1,507 @@++#include "cbase.h"++// OPENVIBE_WINDOWS_SERVER_QUICKJS_STUB++#if defined(_WIN32) && defined(GAME_DLL) && !defined(OPENVIBE_WINDOWS_SERVER_REAL_QUICKJS)++#include "ov_js_entity.h"++++#include "tier0/memdbgon.h"++++JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv) { return JS_NULL; }++JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv) { return JS_UNDEFINED; }++#else++#include "ov_js_entity.h"++#include "util.h"++#include "baseanimating.h"++#include "eventqueue.h"++#include "gamestringpool.h"++#include "vphysics_interface.h"++++#include "tier0/memdbgon.h"++++// ---------------------------------------------------------------------------++// Entity natives — the C++ half of js/core/entity.js. The JS Entity wrapper++// drives real engine entities through OV.entCreate/OV.entCall; anything the++// dispatcher doesn't know returns undefined and the JS side degrades to its++// pure-JS logical entity behavior.++// ---------------------------------------------------------------------------++++// Engine classes JS is allowed to create. Scripted entity classes ("ov_*")++// are also accepted; unknown ov_ classnames fail in CreateEntityByName and++// entCreate returns null, which the JS side treats as "logical entity only".++static const char *s_OVAllowedEntityClasses[] =++{++ "prop_physics",++ "prop_physics_override",++ "prop_dynamic",++ "prop_dynamic_override",++ "info_target",++ "env_sprite",++ "light_dynamic",++};++++static bool OVJS_IsAllowedEntityClass(const char *className)++{++ if (!className || !className[0])++ return false;++++ if (!Q_strnicmp(className, "ov_", 3))++ return true;++++ for (int i = 0; i < ARRAYSIZE(s_OVAllowedEntityClasses); ++i)++ {++ if (!Q_stricmp(className, s_OVAllowedEntityClasses[i]))++ return true;++ }++++ return false;++}++++// Model paths JS may set: mod-relative "models/*.mdl", no traversal.++static bool OVJS_IsAllowedModelPath(const char *path)++{++ if (!path || Q_strnicmp(path, "models/", 7))++ return false;++++ int len = Q_strlen(path);++ if (len < 12 || Q_stricmp(path + len - 4, ".mdl"))++ return false;++++ if (Q_strstr(path, ".."))++ return false;++++ return true;++}++++// ---- guarded reads from the JS args array ----++static double OVJS_ArgNumber(JSContext *ctx, JSValueConst args, uint32 index, double defaultValue)++{++ if (!JS_IsObject(args))++ return defaultValue;++++ JSValue v = JS_GetPropertyUint32(ctx, args, index);++ double out = defaultValue;++++ if (!JS_IsUndefined(v) && !JS_IsException(v))++ {++ double d = 0.0;++ if (JS_ToFloat64(ctx, &d, v) == 0)++ out = d;++ }++++ JS_FreeValue(ctx, v);++ return out;++}++++static int OVJS_ArgInt(JSContext *ctx, JSValueConst args, uint32 index, int defaultValue)++{++ return (int)OVJS_ArgNumber(ctx, args, index, (double)defaultValue);++}++++static bool OVJS_ArgBool(JSContext *ctx, JSValueConst args, uint32 index, bool defaultValue)++{++ if (!JS_IsObject(args))++ return defaultValue;++++ JSValue v = JS_GetPropertyUint32(ctx, args, index);++ bool out = defaultValue;++++ if (!JS_IsUndefined(v) && !JS_IsException(v))++ out = JS_ToBool(ctx, v) != 0;++++ JS_FreeValue(ctx, v);++ return out;++}++++static bool OVJS_ArgString(JSContext *ctx, JSValueConst args, uint32 index, char *out, int outSize)++{++ out[0] = '\0';++++ if (!JS_IsObject(args))++ return false;++++ JSValue v = JS_GetPropertyUint32(ctx, args, index);++ if (!JS_IsUndefined(v) && !JS_IsException(v))++ {++ const char *s = JS_ToCString(ctx, v);++ if (s)++ {++ Q_strncpy(out, s, outSize);++ JS_FreeCString(ctx, s);++ }++ }++++ JS_FreeValue(ctx, v);++ return out[0] != '\0';++}++++static JSValue OVJS_NewVectorObject(JSContext *ctx, const Vector &vec)++{++ JSValue obj = JS_NewObject(ctx);++ JS_SetPropertyStr(ctx, obj, "x", JS_NewFloat64(ctx, vec.x));++ JS_SetPropertyStr(ctx, obj, "y", JS_NewFloat64(ctx, vec.y));++ JS_SetPropertyStr(ctx, obj, "z", JS_NewFloat64(ctx, vec.z));++ return obj;++}++++static JSValue OVJS_NewAngleObject(JSContext *ctx, const QAngle &ang)++{++ JSValue obj = JS_NewObject(ctx);++ JS_SetPropertyStr(ctx, obj, "p", JS_NewFloat64(ctx, ang.x));++ JS_SetPropertyStr(ctx, obj, "y", JS_NewFloat64(ctx, ang.y));++ JS_SetPropertyStr(ctx, obj, "r", JS_NewFloat64(ctx, ang.z));++ return obj;++}++++static int OVJS_ClampColorComponent(int value)++{++ if (value < 0) return 0;++ if (value > 255) return 255;++ return value;++}++++// OV.entCreate(className) -> {entIndex} | null++JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv)++{++ if (argc < 1)++ return JS_NULL;++++ const char *className = JS_ToCString(ctx, argv[0]);++ if (!className)++ return JS_NULL;++++ if (!OVJS_IsAllowedEntityClass(className))++ {++ Warning("[OV JS] OV.entCreate refused class: %s\n", className);++ JS_FreeCString(ctx, className);++ return JS_NULL;++ }++++ CBaseEntity *ent = CreateEntityByName(className);++ JS_FreeCString(ctx, className);++++ if (!ent)++ return JS_NULL;++++ JSValue obj = JS_NewObject(ctx);++ JS_SetPropertyStr(ctx, obj, "entIndex", JS_NewInt32(ctx, ent->entindex()));++ return obj;++}++++// The method dispatcher behind OV.entCall. Unknown methods return undefined so++// the JS wrapper can degrade gracefully.++static JSValue OVJS_EntDispatch(JSContext *ctx, CBaseEntity *ent, const char *method, JSValueConst args)++{++ // ---- lifecycle ----++ if (!Q_strcmp(method, "spawn"))++ {++ DispatchSpawn(ent);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "activate"))++ {++ ent->Activate();++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "remove"))++ {++ UTIL_Remove(ent);++ return JS_UNDEFINED;++ }++++ // ---- appearance ----++ if (!Q_strcmp(method, "setModel"))++ {++ char model[256];++ if (!OVJS_ArgString(ctx, args, 0, model, sizeof(model)))++ return JS_UNDEFINED;++++ if (!OVJS_IsAllowedModelPath(model))++ {++ Warning("[OV JS] entCall setModel refused model: %s\n", model);++ return JS_UNDEFINED;++ }++++ CBaseEntity::PrecacheModel(model);++ ent->SetModel(model);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setModelScale"))++ {++ CBaseAnimating *anim = ent->GetBaseAnimating();++ if (anim)++ anim->SetModelScale((float)OVJS_ArgNumber(ctx, args, 0, 1.0));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setColor"))++ {++ int r = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 0, 255));++ int g = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 1, 255));++ int b = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 2, 255));++ int a = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 3, 255));++ ent->SetRenderColor(r, g, b, a);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setMaterial"))++ {++ char material[256];++ if (OVJS_ArgString(ctx, args, 0, material, sizeof(material)))++ ent->KeyValue("material", material);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setRenderMode"))++ {++ ent->SetRenderMode((RenderMode_t)OVJS_ArgInt(ctx, args, 0, kRenderNormal));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setNoDraw"))++ {++ if (OVJS_ArgBool(ctx, args, 0, true))++ ent->AddEffects(EF_NODRAW);++ else++ ent->RemoveEffects(EF_NODRAW);++ return JS_UNDEFINED;++ }++++ // ---- transform ----++ if (!Q_strcmp(method, "getPos"))++ return OVJS_NewVectorObject(ctx, ent->GetAbsOrigin());++++ if (!Q_strcmp(method, "setPos"))++ {++ Vector pos(++ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));++ ent->Teleport(&pos, NULL, NULL);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "getAngles"))++ return OVJS_NewAngleObject(ctx, ent->GetAbsAngles());++++ if (!Q_strcmp(method, "setAngles"))++ {++ QAngle ang(++ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));++ ent->Teleport(NULL, &ang, NULL);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "getVelocity"))++ return OVJS_NewVectorObject(ctx, ent->GetAbsVelocity());++++ if (!Q_strcmp(method, "setVelocity"))++ {++ Vector vel(++ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));++++ IPhysicsObject *phys = ent->VPhysicsGetObject();++ if (phys)++ phys->SetVelocity(&vel, NULL);++ else++ ent->SetAbsVelocity(vel);++ return JS_UNDEFINED;++ }++++ // ---- health / damage ----++ if (!Q_strcmp(method, "health"))++ return JS_NewInt32(ctx, ent->GetHealth());++++ if (!Q_strcmp(method, "setHealth"))++ {++ ent->SetHealth(OVJS_ArgInt(ctx, args, 0, 0));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setMaxHealth"))++ {++ ent->SetMaxHealth(OVJS_ArgInt(ctx, args, 0, 0));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "takeDamage"))++ {++ float damage = (float)OVJS_ArgNumber(ctx, args, 0, 0.0);++ if (damage < 0.0f)++ return JS_UNDEFINED;++++ int attackerIndex = OVJS_ArgInt(ctx, args, 1, 0);++ CBaseEntity *attacker = attackerIndex > 0 ? UTIL_EntityByIndex(attackerIndex) : NULL;++ if (!attacker)++ attacker = ent;++++ CTakeDamageInfo info(attacker, attacker, damage, DMG_GENERIC);++ ent->TakeDamage(info);++ return JS_UNDEFINED;++ }++++ // ---- movement / collision ----++ if (!Q_strcmp(method, "setMoveType"))++ {++ ent->SetMoveType((MoveType_t)OVJS_ArgInt(ctx, args, 0, MOVETYPE_NONE));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setSolid"))++ {++ ent->SetSolid((SolidType_t)OVJS_ArgInt(ctx, args, 0, SOLID_NONE));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setCollisionGroup"))++ {++ ent->SetCollisionGroup(OVJS_ArgInt(ctx, args, 0, COLLISION_GROUP_NONE));++ return JS_UNDEFINED;++ }++++ // ---- keyvalues / IO ----++ if (!Q_strcmp(method, "setKeyValue"))++ {++ char key[128], value[512];++ if (OVJS_ArgString(ctx, args, 0, key, sizeof(key)))++ {++ OVJS_ArgString(ctx, args, 1, value, sizeof(value));++ ent->KeyValue(key, value);++ }++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "fire"))++ {++ char input[128], param[256];++ if (!OVJS_ArgString(ctx, args, 0, input, sizeof(input)))++ return JS_UNDEFINED;++++ OVJS_ArgString(ctx, args, 1, param, sizeof(param));++ float delay = (float)OVJS_ArgNumber(ctx, args, 2, 0.0);++++ variant_t value;++ if (param[0])++ value.SetString(AllocPooledString(param));++++ g_EventQueue.AddEvent(ent, input, value, delay, NULL, NULL);++ return JS_UNDEFINED;++ }++++ // ---- physics ----++ if (!Q_strcmp(method, "physicsInit"))++ {++ int solidType = OVJS_ArgInt(ctx, args, 0, SOLID_VPHYSICS);++ if (solidType == SOLID_VPHYSICS)++ return ent->VPhysicsInitNormal(SOLID_VPHYSICS, 0, false) ? JS_TRUE : JS_FALSE;++++ ent->SetSolid((SolidType_t)solidType);++ return JS_TRUE;++ }++++ if (!Q_strcmp(method, "physWake"))++ {++ IPhysicsObject *phys = ent->VPhysicsGetObject();++ if (phys)++ phys->Wake();++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "hasPhysics"))++ return ent->VPhysicsGetObject() ? JS_TRUE : JS_FALSE;++++ if (!Q_strcmp(method, "setMass"))++ {++ IPhysicsObject *phys = ent->VPhysicsGetObject();++ float mass = (float)OVJS_ArgNumber(ctx, args, 0, 1.0);++ if (phys && mass > 0.0f)++ phys->SetMass(mass);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "enableMotion"))++ {++ IPhysicsObject *phys = ent->VPhysicsGetObject();++ if (phys)++ phys->EnableMotion(OVJS_ArgBool(ctx, args, 0, true));++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "applyForceCenter"))++ {++ IPhysicsObject *phys = ent->VPhysicsGetObject();++ if (phys)++ {++ Vector force(++ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),++ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));++ phys->ApplyForceCenter(force);++ }++ return JS_UNDEFINED;++ }++++ // ---- hierarchy ----++ if (!Q_strcmp(method, "setParent"))++ {++ int parentIndex = OVJS_ArgInt(ctx, args, 0, 0);++ CBaseEntity *parent = parentIndex > 0 ? UTIL_EntityByIndex(parentIndex) : NULL;++ ent->SetParent(parent);++ return JS_UNDEFINED;++ }++++ if (!Q_strcmp(method, "setOwner"))++ {++ int ownerIndex = OVJS_ArgInt(ctx, args, 0, 0);++ CBaseEntity *owner = ownerIndex > 0 ? UTIL_EntityByIndex(ownerIndex) : NULL;++ ent->SetOwnerEntity(owner);++ return JS_UNDEFINED;++ }++++ // ---- sound ----++ if (!Q_strcmp(method, "emitSound"))++ {++ char sound[256];++ if (OVJS_ArgString(ctx, args, 0, sound, sizeof(sound)))++ {++ CBaseEntity::PrecacheScriptSound(sound);++ ent->EmitSound(sound);++ }++ return JS_UNDEFINED;++ }++++ // Unknown method: undefined, JS side treats it gracefully.++ return JS_UNDEFINED;++}++++// OV.entCall(entIndex, method, argsArray) -> method-dependent | undefined++JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv)++{++ if (argc < 2)++ return JS_UNDEFINED;++++ int32 entIndex = 0;++ JS_ToInt32(ctx, &entIndex, argv[0]);++++ // World (0) and out-of-range indices are not scriptable through this path.++ CBaseEntity *ent = (entIndex > 0 && entIndex < NUM_ENT_ENTRIES) ? UTIL_EntityByIndex(entIndex) : NULL;++ if (!ent || ent->IsMarkedForDeletion())++ return JS_UNDEFINED;++++ const char *method = JS_ToCString(ctx, argv[1]);++ if (!method)++ return JS_UNDEFINED;++++ JSValueConst args = (argc >= 3) ? argv[2] : JS_UNDEFINED;++++ JSValue result = OVJS_EntDispatch(ctx, ent, method, args);++++ JS_FreeCString(ctx, method);++ return result;++}++#endifnnew file mode 100644@@@ -0,0 +1,18 @@++#pragma once++++// OPENVIBE_WINDOWS_SERVER_QUICKJS_STUB++#if defined(_WIN32) && defined(GAME_DLL) && !defined(OPENVIBE_WINDOWS_SERVER_REAL_QUICKJS)++++#include "openvibe/ov_js_runtime.h"++JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);++JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);++#else++#include "openvibe/third_party/quickjs/quickjs.h"++++// Entity native bindings (server realm only). Registered on the OV global by++// OVJS_RegisterNativeBindings:++// OV.entCreate(className) -> {entIndex} | null++// OV.entCall(entIndex, method, args[]) -> method-dependent | undefined++JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);++JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);++#endif@@@ -19,6 +19,12 @@ bool COpenVibeJSRuntime::Init(bool bServerRealm, const char *pszMode)void COpenVibeJSRuntime::Shutdown() {}bool COpenVibeJSRuntime::LoadFile(const char *pszPath) { return false; }bool COpenVibeJSRuntime::Eval(const char *pszCode, const char *pszFilename) { return false; }++bool COpenVibeJSRuntime::RunString(const char *pszCode) { return false; }++bool COpenVibeJSRuntime::RunStringResult(const char *pszCode, char *pszOut, int nOutLen)++{++ if (pszOut && nOutLen > 0) pszOut[0] = '\0';++ return false;++}JSValue COpenVibeJSRuntime::CallHookRaw(const char *pszHookName, int argc, JSValueConst *argv) { return JS_UNDEFINED; }void COpenVibeJSRuntime::CallHookVoid(const char *pszHookName, int argc, JSValueConst *argv) {}bool COpenVibeJSRuntime::CallHookBool(const char *pszHookName, bool *pOut, int argc, JSValueConst *argv)@@@ -117,6 +123,71 @@ bool COpenVibeJSRuntime::Eval(const char *pszCode, const char *pszFilename)return true;}++// js_run backing: eval a console-supplied snippet and log its result/error.++bool COpenVibeJSRuntime::RunString(const char *pszCode)++{++ return RunStringResult(pszCode, NULL, 0);++}++++// Same, but also captures the stringified result / exception text so the++// ConCommand can echo it back to the invoking client's console.++bool COpenVibeJSRuntime::RunStringResult(const char *pszCode, char *pszOut, int nOutLen)++{++ if (pszOut && nOutLen > 0)++ pszOut[0] = '\0';++++ if (!m_pCtx || !pszCode)++ {++ if (pszOut && nOutLen > 0)++ Q_strncpy(pszOut, "runtime is not running", nOutLen);++ return false;++ }++++ JSValue result = JS_Eval(++ m_pCtx,++ pszCode,++ Q_strlen(pszCode),++ "<js_run>",++ JS_EVAL_TYPE_GLOBAL++ );++++ if (JS_IsException(result))++ {++ JSValue exc = JS_GetException(m_pCtx);++ const char *pszExc = JS_ToCString(m_pCtx, exc);++ Warning("[OV JS] <js_run> exception: %s\n", pszExc ? pszExc : "<unprintable>");++ if (pszOut && nOutLen > 0)++ {++ Q_strncpy(pszOut, "ERROR: ", nOutLen);++ Q_strncat(pszOut, pszExc ? pszExc : "<unprintable>", nOutLen, COPY_ALL_CHARACTERS);++ }++ if (pszExc)++ JS_FreeCString(m_pCtx, pszExc);++ JS_FreeValue(m_pCtx, exc);++ JS_FreeValue(m_pCtx, result);++ return false;++ }++++ if (JS_IsUndefined(result))++ {++ Msg("[OV JS] undefined\n");++ if (pszOut && nOutLen > 0)++ Q_strncpy(pszOut, "undefined", nOutLen);++ }++ else++ {++ const char *pszResult = JS_ToCString(m_pCtx, result);++ Msg("[OV JS] %s\n", pszResult ? pszResult : "<unprintable>");++ if (pszOut && nOutLen > 0)++ Q_strncpy(pszOut, pszResult ? pszResult : "<unprintable>", nOutLen);++ if (pszResult)++ JS_FreeCString(m_pCtx, pszResult);++ }++++ JS_FreeValue(m_pCtx, result);++ return true;++}++bool COpenVibeJSRuntime::LoadFile(const char *pszPath){FileHandle_t file = filesystem->Open(pszPath, "rb", "MOD");@@@ -172,7 +243,14 @@ bool COpenVibeJSRuntime::LoadGamemode()char path[256];Q_snprintf(path, sizeof(path), "js/gamemodes/%s/%s.js", m_szMode, m_bServerRealm ? "server" : "client");-- return LoadFile(path);++ bool ok = LoadFile(path);++++ // GModJS loader: run autorun/entities/addons under the embedded backend++ // too. Non-fatal — Eval prints the exception and a stripped js/ tree++ // simply has no OVLoader.++ Eval("globalThis.OVLoader && OVLoader.loadAll && OVLoader.loadAll({mode: OV.getMode()})", "<ov-loader>");++++ return ok;}JSValue COpenVibeJSRuntime::CallHookRaw(const char *pszHookName, int argc, JSValueConst *argv)@@@ -24,6 +24,10 @@ public:bool LoadFile(const char *pszPath);bool Eval(const char *pszCode, const char *pszFilename);++ bool RunString(const char *pszCode);++ // Like RunString, but also copies the stringified result (or the exception++ // text) into pszOut so callers can echo it to the invoking client.++ bool RunStringResult(const char *pszCode, char *pszOut, int nOutLen);JSValue CallHookRaw(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);void CallHookVoid(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);@@@ -51,6 +55,10 @@ public:bool LoadFile(const char *pszPath);bool Eval(const char *pszCode, const char *pszFilename);++ bool RunString(const char *pszCode);++ // Like RunString, but also copies the stringified result (or the exception++ // text) into pszOut so callers can echo it to the invoking client.++ bool RunStringResult(const char *pszCode, char *pszOut, int nOutLen);JSValue CallHookRaw(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);void CallHookVoid(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);@@@ -54,6 +54,10 @@ copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.h" \"$SDK/src/game/shared/openvibe/ov_js_player.h"copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.cpp" \"$SDK/src/game/shared/openvibe/ov_js_player.cpp"++copy_file "$ROOT/sdk/openvibe/shared/ov_js_entity.h" \++ "$SDK/src/game/shared/openvibe/ov_js_entity.h"++copy_file "$ROOT/sdk/openvibe/shared/ov_js_entity.cpp" \++ "$SDK/src/game/shared/openvibe/ov_js_entity.cpp"copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.h" \"$SDK/src/game/shared/openvibe/ovjs_core.h"copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.c" \@@@ -108,6 +112,7 @@ perl -0pi -e 's/^.*openvibe\\ov_js_runtime\.cpp.*\n//mg;s/^.*openvibe\\ov_js_bindings\.cpp.*\n//mg;s/^.*openvibe\\ov_js_player\.cpp.*\n//mg;++ s/^.*openvibe\\ov_js_entity\.cpp.*\n//mg;s/^.*openvibe\\ov_ipc\.cpp.*\n//mg;s/^.*quickjs\\quickjs\.c.*\n//mg;s/^.*quickjs\\libregexp\.c.*\n//mg;@@@ -116,7 +121,7 @@ perl -0pi -e 's/^.*quickjs\\dtoa\.c.*\n//mg;s/^.*quickjs\\libbf\.c.*\n//mg;s/^.*libquickjs_openvibe(?:\.a)?".*\n//mg;-- s/(\$File\s+"hl2mp\\hl2mp_player\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_server.cpp"\n\t\t\t\$File\t"hl2mp\\openvibe_js_server.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_runtime.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_bindings.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_player.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_ipc.cpp"\n\t\t\t\$Lib\t"..\\shared\\openvibe\\third_party\\quickjs\\build\\libquickjs_openvibe"\n/s;++ s/(\$File\s+"hl2mp\\hl2mp_player\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_server.cpp"\n\t\t\t\$File\t"hl2mp\\openvibe_js_server.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_runtime.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_bindings.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_player.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_entity.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_ipc.cpp"\n\t\t\t\$Lib\t"..\\shared\\openvibe\\third_party\\quickjs\\build\\libquickjs_openvibe"\n/s;' "$SERVER_VPC"echo "[openvibe-sdk] patched server_hl2mp.vpc"@@@ -35,9 +35,17 @@ if [[ "${1:-}" != "" && "${2:-}" != "" ]]; thenCONNECT_ARGS=(+connect "$1:$2")fi++# The OpenVibe HTML console replaces the stock Source console; pass++# OPENVIBE_STOCK_CONSOLE=1 to get the engine console back for debugging.++CONSOLE_ARGS=()++if [[ "${OPENVIBE_STOCK_CONSOLE:-0}" == "1" ]]; then++ CONSOLE_ARGS=(-console)++fi++exec "$HL2_LINUX" \-game "$GAME_DIR" \-- -console -dev -novid -sw -w 1280 -h 720 \++ "${CONSOLE_ARGS[@]}" -dev -novid -sw -w 1280 -h 720 \-port 27115 -clientport 27105 \+exec openvibe_proton_client.cfg \++ +exec openvibe_client_default.cfg \"${CONNECT_ARGS[@]}"@@@ -81,16 +81,24 @@ export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"# frame with no error trace. Default off for reliability.export DXVK_ASYNC="${DXVK_ASYNC:-0}"++# The OpenVibe HTML console replaces the stock Source console; pass++# OPENVIBE_STOCK_CONSOLE=1 to get the engine console back for debugging.++CONSOLE_ARGS=""++if [[ "${OPENVIBE_STOCK_CONSOLE:-0}" == "1" ]]; then++ CONSOLE_ARGS="-console"++fi++# Single canonical log via -condebug (writes game/openvibe.games/console.log).# Do NOT also set +con_logfile: it hijacks the console log mid-startup, so the# two split and neither is complete. -condebug alone keeps one full log.exec "$GE_PROTON/proton" waitforexitandrun \"$HL2_EXE" \-game "$GAME_DIR" \-- -console -dev -condebug -novid -sw -w 1280 -h 720 \++ $CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \-port 27115 -clientport 27105 \-nojoy -insecure -nohltv \+developer 1 \+exec openvibe_proton_stability.cfg \+exec openvibe_proton_client.cfg \++ +exec openvibe_client_default.cfg \$CONNECT_ARGS $STARTUP_MAP_ARGS@@@ -57,12 +57,20 @@ export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"# frame with no error trace. Default off for reliability.export DXVK_ASYNC="${DXVK_ASYNC:-0}"++# The OpenVibe HTML console replaces the stock Source console; pass++# OPENVIBE_STOCK_CONSOLE=1 to get the engine console back for debugging.++CONSOLE_ARGS=""++if [[ "${OPENVIBE_STOCK_CONSOLE:-0}" == "1" ]]; then++ CONSOLE_ARGS="-console"++fi++exec "$GE_PROTON/proton" waitforexitandrun \"$HL2_EXE" \-game "$GAME_DIR" \-- -console -dev -condebug -novid -sw -w 1280 -h 720 \++ $CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \-port 27115 -clientport 27105 \+developer 1 \+con_logfile openvibe_proton_console.log \+exec openvibe_proton_client.cfg \++ +exec openvibe_client_default.cfg \$CONNECT_ARGS@@@ -4,6 +4,7 @@ import { createServer } from 'node:http';import { extname, join, normalize, resolve, sep } from 'node:path';const root = resolve(process.env.OPENVIBE_ROOT || join(process.env.HOME || '.', 'src/openvibe-source'));++const clientRoot = join(root, 'client');const launcherRoot = join(root, 'launcher');const host = process.env.OPENVIBE_CLIENT_UI_HOST || '127.0.0.1';const port = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);@@@ -45,14 +46,14 @@ function fileForUrl(url) {const parsed = new URL(url, `http://${host}:${port}`);let pathname = decodeURIComponent(parsed.pathname);++ // The unified OpenVibe app (repo-root client/) is served for /client/*.++ // This matches the backend's static mapping (backend serves client/ at /client/).if (pathname === '/' || pathname === '/client' || pathname === '/client/') {-- return join(launcherRoot, 'index.html');++ return join(clientRoot, 'index.html');}if (!pathname.startsWith('/client/')) {-- if (pathname === '/styles.css' || pathname === '/renderer.js' || pathname === '/preload.js') {-- return join(launcherRoot, pathname.slice(1));-- }++ // Launcher assets (icons etc.) stay available for anything that still references them.if (pathname.startsWith('/assets/')) {const asset = normalize(join(launcherRoot, pathname.slice(1)));return asset.startsWith(launcherRoot + sep) ? asset : null;@@@ -61,8 +62,8 @@ function fileForUrl(url) {}pathname = pathname.slice('/client/'.length);-- const file = normalize(join(launcherRoot, pathname));-- if (!file.startsWith(launcherRoot + sep)) {++ const file = normalize(join(clientRoot, pathname));++ if (!file.startsWith(clientRoot + sep)) {return null;}@@@ -104,5 +105,5 @@ const server = createServer((req, res) => {});server.listen(port, host, () => {-- console.log(`[openvibe-ui] serving ${launcherRoot} at http://${host}:${port}/client`);++ console.log(`[openvibe-ui] serving ${clientRoot} at http://${host}:${port}/client`);});nnew file mode 100644@@@ -0,0 +1,115 @@++#!/usr/bin/env node++// End-to-end IPC smoke: boots the server-realm and client-realm ov-runtime++// hosts on real TCP ports, connects fake "game DLL" sockets to both (playing++// the C++ side), and drives the full message flow:++// hello -> framework load -> player_connect -> PlayerSay -> net C->S -> net S->C++// Verifies the sandbox Q-menu spawn round-trips and the HUD/net wiring works++// across processes exactly as it will in-game (ov_js_backend node).++import { spawn } from "node:child_process";++import net from "node:net";++import path from "node:path";++import url from "node:url";++++const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");++const RT = path.join(ROOT, "engine", "openvibe-js-runtime", "ov-runtime.js");++++const SV_PORT = 46999, SV_CTRL = 46997, CL_PORT = 46998, CL_CTRL = 46996;++++function wait(ms) { return new Promise((r) => setTimeout(r, ms)); }++++function startRuntime(realm, port, ctrl) {++ const p = spawn(process.execPath, [RT, "--realm", realm, "--mode", "sandbox",++ "--port", String(port), "--ctrl-port", String(ctrl), "--root", ROOT], { stdio: ["ignore", "pipe", "pipe"] });++ const lines = [];++ p.stdout.on("data", (d) => lines.push(...String(d).split("\n").filter(Boolean)));++ p.stderr.on("data", (d) => lines.push(...String(d).split("\n").filter(Boolean)));++ return { proc: p, lines };++}++++function connectGame(port) {++ return new Promise((resolve, reject) => {++ const inbox = [];++ const sock = net.connect(port, "127.0.0.1", () => resolve({ sock, inbox, send: (o) => sock.write(JSON.stringify(o) + "\n") }));++ let buf = "";++ sock.on("data", (d) => {++ buf += d;++ let nl;++ while ((nl = buf.indexOf("\n")) >= 0) {++ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);++ if (line) { try { inbox.push(JSON.parse(line)); } catch {} }++ }++ });++ sock.on("error", reject);++ });++}++++let failures = 0;++function ok(cond, label) {++ if (!cond) { failures++; console.error(` FAIL ${label}`); }++ else console.log(` ok ${label}`);++}++++const sv = startRuntime("server", SV_PORT, SV_CTRL);++const cl = startRuntime("client", CL_PORT, CL_CTRL);++await wait(1500);++++try {++ // fake game DLL connections (what the C++ COpenVibeIPC does)++ const svGame = await connectGame(SV_PORT);++ const clGame = await connectGame(CL_PORT);++++ svGame.send({ t: "hello", map: "ov_hub" });++ clGame.send({ t: "hello", map: "ov_hub" });++ await wait(700);++++ svGame.send({ t: "player_connect", userId: 3, name: "Echo", steamId: "STEAM_0:1:3", entIndex: 1 });++ clGame.send({ t: "local_player", userId: 3, name: "Echo", steamId: "STEAM_0:1:3", entIndex: 1 });++ await wait(200);++++ // PlayerSay routes chat commands: !q prints Q menu via {"t":"chat"} lines back++ svGame.send({ t: "say", userId: 3, playerName: "Echo", text: "!q" });++ await wait(300);++ ok(svGame.inbox.some((m) => m.t === "chat" && /Q Menu/.test(m.msg || "")), "server: !q chat command replied via IPC");++++ // client -> server net: Q-menu spawn (client realm net.SendToServer -> game 'net' line)++ const evalRes = await fetch(`http://127.0.0.1:${CL_CTRL}/eval`, {++ method: "POST", body: JSON.stringify({ code: 'net.Start("OV_Sandbox_Spawn");net.WriteString("crate");net.SendToServer();"sent"' })++ }).then((r) => r.json());++ ok(evalRes.ok && evalRes.result === "sent", "client: eval net.SendToServer");++ await wait(200);++ const clNetOut = clGame.inbox.find((m) => m.t === "net" && m.toServer);++ ok(!!clNetOut, "client runtime emitted net line to game DLL (ov_net path)");++++ // game DLL would forward that to the server as {"t":"net", userId, ...}++ svGame.send({ t: "net", userId: 3, name: clNetOut.name, payload: clNetOut.payload });++ await wait(300);++ ok(svGame.inbox.some((m) => m.t === "runcmd" && /ov_fortwars_spawn crate/.test(m.cmd || "")), "server: net.Receive validated + ran spawn command");++++ // server -> client net: OV_Sandbox_Welcome on spawn++ svGame.send({ t: "event", name: "PlayerInitialSpawn", args: [{ __player: true, userId: 3 }] });++ await wait(300);++ const welcome = svGame.inbox.find((m) => m.t === "net" && !m.toServer);++ ok(!!welcome, "server: PlayerInitialSpawn pushed net message to client");++ if (welcome) {++ // game DLL delivers it to the client runtime via the OVNet usermessage path++ clGame.send({ t: "net", name: welcome.name, payload: welcome.payload });++ await wait(300);++ const state = await fetch(`http://127.0.0.1:${CL_CTRL}/eval`, {++ method: "POST", body: JSON.stringify({ code: "1" })++ }).then((r) => r.json());++ ok(state.ok, "client runtime alive after net delivery");++ ok(cl.lines.some((l) => /OV_Sandbox_Welcome/.test(l)), "client: net.Receive fired for OV_Sandbox_Welcome");++ }++++ // hot-reload signal: server /state shows gamemode + hook events++ const st = await fetch(`http://127.0.0.1:${SV_CTRL}/state`).then((r) => r.json());++ ok(st.ok && st.gamemode && st.gamemode.mode === "sandbox" && st.gameConnected, "server /state reflects connected game + gamemode");++} catch (e) {++ failures++;++ console.error(" FAIL harness error:", e.message);++} finally {++ sv.proc.kill(); cl.proc.kill();++}++++if (failures) { console.error(`[smoke-runtime-ipc] ${failures} FAILURES`); process.exit(1); }++console.log("[smoke-runtime-ipc] ALL PASS");nnew file mode 100644@@@ -0,0 +1,156 @@++#!/usr/bin/env node++// Smoke test for the unified OpenVibe GUI (client/).++//++// 1. Starts tools/serve-client-ui.mjs on an ephemeral port and asserts:++// - GET /health → { ok: true }++// - GET /client/ → unified HTML with markers for every route++// - GET /client/ui-sync.js → the shared sync layer serves++// 2. Verifies the backend path mapping (backend/src/index.ts serves the++// repo-root client/ directory at /client/) by reading client/index.html++// straight from disk — no backend start needed — and asserting it is the++// same unified app.++//++// Exits non-zero on any failure.++++import { spawn } from 'node:child_process';++import { readFileSync } from 'node:fs';++import { createServer } from 'node:net';++import { dirname, join, resolve } from 'node:path';++import { fileURLToPath } from 'node:url';++++const __dirname = dirname(fileURLToPath(import.meta.url));++const root = resolve(__dirname, '..');++const serverScript = join(__dirname, 'serve-client-ui.mjs');++const clientIndex = join(root, 'client', 'index.html');++++const ROUTE_MARKERS = [++ 'route-portal',++ 'route-servers',++ 'route-leaderboard',++ 'route-inventory',++ 'route-shop',++ 'route-settings',++ 'route-options',++ 'route-console',++];++++let failures = 0;++function check(name, ok, detail = '') {++ if (ok) {++ console.log(` ok ${name}`);++ } else {++ failures += 1;++ console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);++ }++}++++function ephemeralPort() {++ return new Promise((resolvePort, reject) => {++ const srv = createServer();++ srv.listen(0, '127.0.0.1', () => {++ const { port } = srv.address();++ srv.close(() => resolvePort(port));++ });++ srv.on('error', reject);++ });++}++++async function waitForHealth(base, timeoutMs = 8000) {++ const started = Date.now();++ while (Date.now() - started < timeoutMs) {++ try {++ const res = await fetch(`${base}/health`);++ if (res.ok) return true;++ } catch {}++ await new Promise((r) => setTimeout(r, 150));++ }++ return false;++}++++function assertUnifiedHtml(label, html) {++ for (const marker of ROUTE_MARKERS) {++ check(`${label}: contains ${marker}`, html.includes(marker));++ }++ check(`${label}: contains HUD overlay (ov-hud)`, html.includes('ov-hud'));++ check(`${label}: loads ui-sync.js`, html.includes('ui-sync.js'));++ check(`${label}: openvibe:// bridge present`, html.includes('openvibe://'));++ check(`${label}: console eval wiring (41997)`, html.includes('41997'));++ check(`${label}: client runtime wiring (41996)`, html.includes('41996'));++}++++async function main() {++ const port = await ephemeralPort();++ const base = `http://127.0.0.1:${port}`;++++ console.log(`[smoke-ui] starting serve-client-ui.mjs on :${port}`);++ const child = spawn(process.execPath, [serverScript], {++ cwd: root,++ env: {++ ...process.env,++ OPENVIBE_ROOT: root,++ OPENVIBE_CLIENT_UI_PORT: String(port),++ OPENVIBE_CLIENT_UI_HOST: '127.0.0.1',++ },++ stdio: ['ignore', 'pipe', 'pipe'],++ });++ child.stderr.on('data', (d) => process.stderr.write(`[serve] ${d}`));++++ try {++ check('server: /health reachable', await waitForHealth(base));++++ // /health payload++ try {++ const health = await (await fetch(`${base}/health`)).json();++ check('server: /health ok:true', health && health.ok === true, JSON.stringify(health));++ } catch (e) {++ check('server: /health ok:true', false, e.message);++ }++++ // /client/ serves the unified app++ try {++ const res = await fetch(`${base}/client/`);++ check('server: GET /client/ → 200', res.status === 200, `status ${res.status}`);++ check('server: /client/ is HTML', (res.headers.get('content-type') || '').includes('text/html'));++ const html = await res.text();++ assertUnifiedHtml('server: /client/', html);++ } catch (e) {++ check('server: GET /client/', false, e.message);++ }++++ // /client/ui-sync.js serves++ try {++ const res = await fetch(`${base}/client/ui-sync.js`);++ check('server: GET /client/ui-sync.js → 200', res.status === 200, `status ${res.status}`);++ const js = await res.text();++ check('server: ui-sync storage key', js.includes('openvibe.ui.state.v1'));++ check('server: ui-sync channel', js.includes('openvibe-ui-sync-v1'));++ check('server: ui-sync routes include console/options/hud',++ js.includes("'console'") && js.includes("'options'") && js.includes("'hud'"));++ check('server: ui-sync exposes OpenVibeShell', js.includes('OpenVibeShell'));++ } catch (e) {++ check('server: GET /client/ui-sync.js', false, e.message);++ }++++ // Backend path mapping: backend serves the same repo-root client/ dir at++ // /client/ — assert the on-disk file is the unified app.++ try {++ const html = readFileSync(clientIndex, 'utf8');++ assertUnifiedHtml('backend(disk): client/index.html', html);++ } catch (e) {++ check('backend(disk): read client/index.html', false, e.message);++ }++ } finally {++ child.kill();++ }++++ if (failures > 0) {++ console.error(`[smoke-ui] FAILED — ${failures} assertion(s) failed`);++ process.exit(1);++ }++ console.log('[smoke-ui] PASS — all assertions succeeded');++}++++main().catch((e) => {++ console.error('[smoke-ui] fatal:', e);++ process.exit(1);++});nnew file mode 100644@@@ -0,0 +1,484 @@++#!/usr/bin/env node++// GModJS framework test harness — runs a paired server+client realm entirely++// in Node (zero C++), with a loopback net transport, against the real++// game/openvibe.games/js tree. Covers hooks, net, entities, scripted ents,++// NW/DT sync, file sync manifest, include/AddCSJSFile, concommand, loader,++// and hot-repatch of scripted entities.++import fs from "node:fs";++import path from "node:path";++import vm from "node:vm";++import url from "node:url";++++const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");++const MOD = path.join(ROOT, "game", "openvibe.games");++++let failures = 0;++let checks = 0;++function ok(cond, label) {++ checks++;++ if (!cond) { failures++; console.error(` FAIL ${label}`); }++ else console.log(` ok ${label}`);++}++function section(name) { console.log(`\n== ${name}`); }++++function modRel(p) {++ const norm = path.normalize(p).replace(/^([/\\]|\.\.)+/, "");++ return path.join(MOD, norm);++}++++// ---- realm factory ----++function makeRealm(realmName, mode, transport) {++ const logs = [];++ const isServer = realmName === "server";++ const players = {}; // userId -> record++ const writes = {}; // virtual client write cache (js/ov_downloads/...)++++ const OV = {++ log: (m) => logs.push(`[log] ${m}`),++ warn: (m) => logs.push(`[warn] ${m}`),++ error: (m) => { logs.push(`[error] ${m}`); console.error(` [${realmName} JS ERROR]`, m); },++ isServer: () => isServer,++ getMode: () => mode,++ getMapName: () => "test_map",++ time: () => transport.now(),++ readFile: (p) => {++ if (writes[p] !== undefined) return writes[p];++ try { return fs.readFileSync(modRel(p), "utf8"); } catch { return null; }++ },++ fileExists: (p) => {++ if (writes[p] !== undefined) return true;++ try { fs.statSync(modRel(p)); return true; } catch { return false; }++ },++ listDir: (d) => { try { return fs.readdirSync(modRel(d)); } catch { return []; } },++ writeFile: (p, content) => { writes[p] = String(content); return true; },++ players: () => Object.values(players).map((p) => p.native),++ playerByUserId: (id) => (players[id] ? players[id].native : null),++ broadcast: (m) => logs.push(`[broadcast] ${m}`),++ serverCommand: (c) => logs.push(`[serverCommand] ${c}`),++ netEmit: (ids, name, payload) => transport.serverEmit(ids, name, payload),++ netSendToServer: (name, payload) => transport.clientSend(name, payload),++ fireHook: (...a) => ctxg.hook.Run(...a),++ localPlayer: () => null,++ reward: () => {}, endMatch: () => {},++ };++++ const ctxg = vm.createContext({ console, OV });++ ctxg.globalThis = ctxg;++++ const core = ["core/hook.js", "core/gamemode.js", "bridge.js", "core/command.js", "core/timer.js"];++ for (const f of core) {++ const code = fs.readFileSync(path.join(MOD, "js", f), "utf8");++ vm.runInContext(code, ctxg, { filename: `js/${f}` });++ }++ ctxg.OVLoader.loadAll({ mode });++++ function addPlayer(userId, name) {++ const rec = { userId, name, team: 0, health: 100 };++ rec.native = {++ userId: () => userId, entIndex: () => userId,++ steamId: () => `STEAM_0:1:${userId}`, name: () => name,++ health: () => rec.health, setHealth: (v) => { rec.health = v; },++ team: () => rec.team, setTeam: (v) => { rec.team = v; },++ chat: (m) => logs.push(`[chat:${name}] ${m}`),++ runCommand: (c) => logs.push(`[runCommand:${name}] ${c}`),++ };++ players[userId] = rec;++ return rec.native;++ }++++ return { name: realmName, OV, ctx: ctxg, logs, addPlayer, writes };++}++++// ---- loopback transport with virtual clock ----++function makeTransport() {++ let t = 1000;++ const queue = [];++ const api = {++ now: () => t,++ tick: (dt = 0.1) => { t += dt; api.flush(); },++ flush: () => {++ while (queue.length) {++ const m = queue.shift();++ try { m(); } catch (e) { console.error(" transport dispatch error:", e.message); }++ }++ },++ serverEmit: null, clientSend: null,++ };++ return api;++}++++const transport = makeTransport();++const server = makeRealm("server", "sandbox", transport);++const client = makeRealm("client", "sandbox", transport);++++// Wire loopback AFTER both realms exist.++transport.serverEmit = (idsCsv, name, payload) => {++ queueDispatch(client, name, payload, null);++};++let serverSenderPly = null;++transport.clientSend = (name, payload) => {++ queueDispatch(server, name, payload, serverSenderPly);++};++const pending = [];++function queueDispatch(realm, name, payload, ply) {++ pending.push(() => realm.ctx.hook.Run("OVNetReceive", name, payload, ply));++}++function pump() {++ while (pending.length) {++ const fn = pending.shift();++ fn();++ }++}++++// The transport in makeRealm captured `transport` closure functions set above.++++section("realms");++ok(server.ctx.SERVER === true && server.ctx.CLIENT === false, "server realm globals");++ok(client.ctx.SERVER === false && client.ctx.CLIENT === true, "client realm globals");++ok(typeof server.ctx.OVLoader === "object", "loader present");++ok(typeof server.ctx.ents === "object" && typeof server.ctx.Entity === "function", "entity system present");++ok(typeof server.ctx.AddCSJSFile === "function" && typeof server.ctx.include === "function", "file library present");++++section("hook semantics");++{++ const H = server.ctx.hook;++ let calls = [];++ H.Add("TestHook", "a", () => { calls.push("a"); });++ H.Add("TestHook", "b", () => { calls.push("b"); return false; });++ H.Add("TestHook", "c", () => { calls.push("c"); });++ const result = H.Run("TestHook");++ ok(result === false, "false short-circuits (not treated as nil)");++ ok(calls.join(",") === "a,b", "later hooks skipped after short-circuit");++ H.Remove("TestHook", "b"); H.Remove("TestHook", "a"); H.Remove("TestHook", "c");++++ // object identifier with IsValid++ const obj = { valid: true, IsValid() { return this.valid; }, hits: 0 };++ H.Add("ObjHook", obj, (self) => { self.hits++; });++ H.Run("ObjHook");++ ok(obj.hits === 1, "object identifier receives self");++ obj.valid = false;++ H.Run("ObjHook");++ ok(obj.hits === 1, "invalid object hook auto-removed");++ ok((H.GetTable("ObjHook") || []).length === 0, "auto-removed from table");++}++++section("gamemode inheritance");++{++ const g = server.ctx;++ ok(g.GAMEMODE && g.GAMEMODE.mode === "sandbox", "active gamemode is sandbox");++ ok(typeof g.gamemode.Register === "function" && typeof g.DeriveGamemode === "function", "Register/DeriveGamemode exist");++ ok(g.baseclass.Get("gamemode_base") !== null, "baseclass gamemode_base set");++}++++section("net library");++{++ const S = server.ctx, C = client.ctx;++ S.util.AddNetworkString("TestMsg");++ let got = null;++ C.net.Receive("testmsg", () => { // case-insensitive++ got = {++ i: C.net.ReadInt(), u: C.net.ReadUInt(), f: C.net.ReadFloat(),++ b: C.net.ReadBool(), bit: C.net.ReadBit(), s: C.net.ReadString(),++ d: C.net.ReadData(4), v: C.net.ReadVector(), a: C.net.ReadAngle(),++ c: C.net.ReadColor(), t: C.net.ReadTable(), u64: C.net.ReadUInt64(),++ past: C.net.ReadString(),++ };++ });++ ok(S.net.Start("TestMsg") === true, "net.Start on pooled name");++ S.net.WriteInt(-42, 16); S.net.WriteUInt(42, 8); S.net.WriteFloat(3.25);++ S.net.WriteBool(true); S.net.WriteBit(true); S.net.WriteString("hello");++ S.net.WriteData("abcdEXTRA", 4); S.net.WriteVector({ x: 1, y: 2, z: 3 });++ S.net.WriteAngle({ p: 10, y: 20, r: 30 }); S.net.WriteColor({ r: 255, g: 128, b: 0 });++ S.net.WriteTable({ k: "v", n: 5 }); S.net.WriteUInt64("18446744073709551615");++ S.net.Broadcast();++ pump();++ ok(got !== null, "client received broadcast");++ ok(got && got.i === -42 && got.u === 42 && Math.abs(got.f - 3.25) < 1e-9, "numeric roundtrip");++ ok(got && got.b === true && got.bit === 1, "bool/bit roundtrip (bit reads as number)");++ ok(got && got.s === "hello" && got.d === "abcd", "string/data roundtrip");++ ok(got && got.v.z === 3 && got.a.r === 30 && got.c.a === 255, "vector/angle/color roundtrip");++ ok(got && got.t.k === "v" && got.u64 === "18446744073709551615", "table/uint64 roundtrip");++ ok(got && got.past === "", "read past end returns default");++++ ok(S.net.Start("NotPooled") === false, "net.Start rejects unpooled name on server");++++ // client -> server with sender identity++ const alice = server.addPlayer(7, "Alice");++ serverSenderPly = alice;++ let senderSeen = null, ageSeen = 0;++ S.util.AddNetworkString("SendAge");++ S.net.Receive("SendAge", (len, ply) => { ageSeen = S.net.ReadUInt(8); senderSeen = ply; });++ C.net.Start("SendAge"); C.net.WriteUInt(29, 8); C.net.SendToServer();++ pump();++ ok(ageSeen === 29, "client->server payload");++ ok(senderSeen === alice, "server receiver gets authoritative sender");++++ // chunking: payload far above CHUNK_SIZE++ S.util.AddNetworkString("BigMsg");++ const bigString = "x".repeat(5000);++ let bigGot = null;++ C.net.Receive("BigMsg", () => { bigGot = C.net.ReadString(); });++ S.net.Start("BigMsg"); S.net.WriteString(bigString); S.net.Broadcast();++ pump();++ ok(bigGot === bigString, "chunked message reassembled (5KB)");++++ // oversize cap++ S.net.Start("BigMsg"); S.net.WriteString("y".repeat(70000));++ S.net.Broadcast();++ pump();++ ok(bigGot === bigString, "oversize >64KB message dropped");++++ // rate limiting++ S.util.AddNetworkString("Spammy");++ let spamCount = 0;++ S.net.Receive("Spammy", () => { spamCount++; });++ S.net.SetRateLimit("Spammy", 3);++ for (let i = 0; i < 10; i++) { C.net.Start("Spammy"); C.net.SendToServer(); }++ pump();++ ok(spamCount === 3, `rate limit enforced (got ${spamCount}/10, cap 3)`);++}++++section("entities + scripted ents");++{++ const S = server.ctx;++ S.scripted_ents.Register({++ Type: "anim",++ Base: "base_gmodentity",++ PrintName: "Test Crate",++ SetupDataTables() { this.NetworkVar("Int", 0, "Charge"); },++ Initialize() { this.initialized = true; this.SetModel("models/crate.mdl"); },++ Think() { this.thinks = (this.thinks || 0) + 1; },++ OnTakeDamage(info) { this.SetHealth(this.Health() - info.GetDamage()); if (this.Health() <= 0) this.Remove(); },++ OnRemove() { this.removedHookRan = true; },++ }, "test_crate");++++ ok(S.scripted_ents.GetStored("test_crate") !== null, "scripted_ents.Register stored");++ ok(S.scripted_ents.IsBasedOn("test_crate", "base_anim"), "IsBasedOn walks Base chain");++++ const crate = S.ents.Create("test_crate");++ ok(crate.IsValid(), "ents.Create returns valid entity");++ ok(typeof crate.GetCharge === "function", "SetupDataTables ran at creation (NetworkVar accessors)");++ crate.SetPos({ x: 10, y: 0, z: 0 });++ crate.Spawn();++ ok(crate.initialized === true, "Spawn runs Initialize");++ ok(crate.GetModel() === "models/crate.mdl", "SetModel/GetModel");++ ok(crate.GetClass() === "test_crate", "GetClass");++++ // find queries++ ok(S.ents.FindByClass("test_*").length === 1, "FindByClass wildcard");++ ok(S.ents.FindInSphere({ x: 0, y: 0, z: 0 }, 20).includes(crate), "FindInSphere hit");++ // (players idle at the origin also live in the entity list, so probe far away)++ ok(S.ents.FindInSphere({ x: 500, y: 500, z: 500 }, 5).length === 0, "FindInSphere miss");++ ok(S.Entity(crate.EntIndex()) === crate, "Entity(index) resolves");++++ // think pump++ transport.tick();++ S.gamemode.call("Think");++ ok(crate.thinks >= 1, "ENT:Think driven by Think hook");++++ // NextThink schedule++ const before = crate.thinks;++ crate.NextThink(transport.now() + 100);++ S.gamemode.call("Think");++ ok(crate.thinks === before, "NextThink defers Think");++++ // NW + DT replication to client shell++ S.util; // pooled names already registered by entity.js++ crate.SetNWString("label", "hi");++ crate.SetCharge(7);++ pump();++ const shell = client.ctx.ents.GetByIndex(crate.EntIndex());++ ok(shell && shell.IsValid(), "client shell entity materialized");++ ok(shell && shell.GetNWString("label") === "hi", "NW var replicated to client");++ ok(shell && shell._r.dt.Charge === 7, "DTVar replicated to client");++++ // damage + removal lifecycle++ crate.SetHealth(10);++ let removedFired = false;++ crate.CallOnRemove("test", () => { removedFired = true; });++ crate.TakeDamage(25, S.NULL, S.NULL);++ ok(crate.Health() <= 0, "OnTakeDamage applied damage");++ S.gamemode.call("Think"); // flush deferred removals++ ok(!crate.IsValid(), "Remove finalized at end of tick");++ ok(removedFired && crate.removedHookRan === true, "CallOnRemove + OnRemove fired");++++ // hot-repatch++ const crate2 = S.ents.Create("test_crate");++ crate2.Spawn();++ S.scripted_ents.Register({++ Type: "anim", Base: "base_gmodentity",++ Think() { this.newThink = true; },++ OnReloaded() { this.reloaded = true; },++ }, "test_crate");++ ok(crate2.reloaded === true, "re-register hot-patches live instances (OnReloaded)");++ S.gamemode.call("Think");++ ok(crate2.newThink === true, "live instance uses new methods");++ crate2.Remove(); S.gamemode.call("Think");++++ // clientside entity++ const cs = client.ctx.ents.CreateClientside("test_crate");++ ok(client.ctx.ents.Create("x").IsValid() === false, "ents.Create is server-only (returns NULL)");++ ok(cs.EntIndex() === -1, "clientside entity reports EntIndex -1");++}++++section("player class");++{++ const S = server.ctx;++ const bobNative = server.addPlayer(9, "Bob");++ const bob = S.Player.fromNative(bobNative);++ ok(bob.__ovPlayer === true && bob.IsPlayer(), "Player.fromNative wraps");++ ok(S.Player.fromNative(bobNative) === bob, "wrap cached by UserID");++ ok(bob.Nick() === "Bob" && bob.UserID() === 9, "identity accessors");++ bob.SetTeam(3);++ ok(bob.Team() === 3 && bobNative.team() === 3, "SetTeam hits native handle");++ ok(bob.name() === "Bob" && typeof bob.chat === "function", "legacy lowercase API intact");++ ok(S.player.GetByUserID(9) === bob, "player.GetByUserID");++ ok(S.player.GetBySteamID("STEAM_0:1:9") === bob, "player.GetBySteamID");++ // gamemode.call wraps native handles++ let seen = null;++ S.hook.Add("WrapCheck", "t", (p) => { seen = p; return true; });++ S.gamemode.call("WrapCheck", bobNative);++ ok(seen === bob, "gamemode.call wraps native player args");++ S.hook.Remove("WrapCheck", "t");++}++++section("file library / AddCSJSFile / include");++{++ const S = server.ctx;++ const manifest = S.file.BuildClientManifest();++ ok(Array.isArray(manifest) && manifest.length > 0, `client manifest built (${manifest.length} files)`);++ ok(manifest.some((f) => f.p.startsWith("js/gamemodes/sandbox/"), ), "manifest includes gamemode client files");++ ok(manifest.every((f) => f.h && f.s > 0), "manifest entries carry hash+size");++++ S.AddCSJSFile("core/util.js");++ const manifest2 = S.file.BuildClientManifest();++ ok(manifest2.some((f) => f.p === "js/core/util.js"), "AddCSJSFile marks extra file");++++ const inc = S.include("core/util.js");++ ok(S.util.CRC("abc") === S.util.CRC("abc"), "include re-ran a core file without error");++++ // client sync: manifest -> request -> data -> OVFilesSynced++ let synced = false;++ client.ctx.hook.Add("OVFilesSynced", "t", () => { synced = true; });++ // simulate: make one file 'missing' on the client by hashing a bogus cache++ client.writes["js/gamemodes/sandbox/client.js"] = "// stale";++ const ply7 = S.player.GetByUserID(7);++ S.hook.Run("PlayerInitialSpawn", ply7);++ // fire timers (manifest send is delayed 2s)++ transport.tick(3);++ S.gamemode.call("Think");++ pump(); // manifest to client -> request to server -> data to client++ pump();++ ok(synced === true, "client file sync completed (OVFilesSynced)");++ const dl = client.writes["js/ov_downloads/js/gamemodes/sandbox/client.js"];++ ok(typeof dl === "string" && dl.length > 10, "stale file downloaded into ov_downloads cache");++}++++section("concommand + js_run/js_openscript");++{++ const S = server.ctx;++ let ranArgs = null;++ S.concommand.Add("test_cmd", (ply, cmd, args, argStr) => { ranArgs = { ply, cmd, args, argStr }; });++ ok(S.concommand.Dispatch(null, 'test_cmd one "two three"') === true, "concommand.Dispatch handles");++ ok(ranArgs && ranArgs.args.length === 2 && ranArgs.args[1] === "two three", "quoted arg parsing");++ ok(S.gamemode.call("ConsoleCommand", "test_cmd x") === false, "ConsoleCommand hook routes to concommand");++++ const r1 = S.OVLoader.runString("1 + 41", "test");++ ok(r1.ok && r1.result === 42, "runString (js_run) returns value");++ const r2 = S.OVLoader.runString("throw new Error('boom')", "test");++ ok(!r2.ok && /boom/.test(r2.error), "runString reports errors");++++ ok(S.OVLoader.openScript("core/util.js") === true, "openScript (js_openscript) runs js/-relative path");++ ok(S.OVLoader.openScript("nope/missing.js") === false, "openScript rejects missing path");++++ let blockedRan = false;++ S.concommand.Add("js_run_cl", () => { blockedRan = true; });++ S.RunConsoleCommand("js_run_cl", "code");++ ok(blockedRan === false, "RunConsoleCommand blocklist protects js_run_cl");++}++++section("npm require (node_modules)");++{++ // The sandbox gamemode requires ov-leftpad through the embedded module++ // system (core/module.js over OV.readFile) — prove bare-specifier reqs work.++ const S = server.ctx;++ const r = S.OVLoader.runString("require('ov-leftpad')('7', 3, '0')", "npm-test");++ ok(r.ok && r.result === "007", `require('ov-leftpad') via js/node_modules${r.ok ? "" : " — " + r.error}`);++}++++section("all gamemodes end-to-end (server+client realm pairs)");++for (const mode of ["hub", "sandbox", "prophunt", "deathrun", "fortwars", "traitortown"]) {++ const tp = makeTransport();++ const sv = makeRealm("server", mode, tp);++ const cl = makeRealm("client", mode, tp);++ const q = [];++ tp.serverEmit = (ids, name, payload) => q.push(() => cl.ctx.hook.Run("OVNetReceive", name, payload, null));++ let sender = null;++ tp.clientSend = (name, payload) => q.push(() => sv.ctx.hook.Run("OVNetReceive", name, payload, sender));++ const drain = () => { while (q.length) q.shift()(); };++++ const hudSeen = [];++ cl.ctx.hook.Add("OVHudState", "t", (s) => { hudSeen.push(s); });++ const rolesSeen = [];++ cl.ctx.hook.Add("OVRoleAssigned", "t", (role) => { rolesSeen.push(role); });++++ // players join++ const p1 = sv.addPlayer(1, "Alpha"), p2 = sv.addPlayer(2, "Bravo");++ sender = p1;++ sv.ctx.gamemode.call("Initialize");++ sv.ctx.gamemode.call("MapInitialize", "test_map");++ sv.ctx.gamemode.call("PlayerInitialSpawn", p1);++ sv.ctx.gamemode.call("PlayerInitialSpawn", p2);++ sv.ctx.gamemode.call("PlayerSpawn", p1);++ sv.ctx.gamemode.call("PlayerSpawn", p2);++ drain();++++ cl.ctx.gamemode.call("Initialize");++++ ok(sv.ctx.GAMEMODE && sv.ctx.GAMEMODE.mode === mode, `[${mode}] server GAMEMODE active`);++ ok(cl.ctx.GAMEMODE && cl.ctx.GAMEMODE.mode === (["hub", "sandbox"].includes(mode) ? mode : mode), `[${mode}] client GAMEMODE active`);++ ok(sv.ctx.scripted_ents.GetStored("ov_bouncy_crate") !== null, `[${mode}] SENT registered on server`);++ ok(cl.ctx.scripted_ents.GetStored("ov_bouncy_crate") !== null, `[${mode}] SENT registered on client`);++++ const roundModes = ["prophunt", "deathrun", "fortwars", "traitortown"];++ if (roundModes.includes(mode)) {++ ok(Object.keys(sv.ctx.team.GetAllTeams()).length >= 3, `[${mode}] CreateTeams populated team library`);++ // run countdown -> round start++ for (let i = 0; i < 20; i++) { tp.tick(1); sv.ctx.gamemode.call("Think"); drain(); }++ ok(sv.ctx.GAMEMODE._roundState === "active", `[${mode}] round started (state=${sv.ctx.GAMEMODE._roundState})`);++ const t1 = p1.team ? null : null;++ const teams = [sv.ctx.player.GetByUserID(1).Team(), sv.ctx.player.GetByUserID(2).Team()];++ ok(teams.every((t) => t === 2 || t === 3), `[${mode}] players assigned to teams (${teams})`);++ if (mode !== "fortwars") {++ ok(rolesSeen.length > 0, `[${mode}] client received role via net (${rolesSeen.join(",")})`);++ }++ // HUD ticker++ for (let i = 0; i < 4; i++) { tp.tick(1); sv.ctx.gamemode.call("Think"); drain(); }++ ok(hudSeen.length > 0 && hudSeen[hudSeen.length - 1].state === "active", `[${mode}] HUD state replicated to client`);++ ok(hudSeen[hudSeen.length - 1].teams.length >= 2, `[${mode}] HUD state carries team roster`);++ // player death -> win condition path exercises endRound++ sv.ctx.gamemode.call("PlayerDeath", p1, p2);++ ok(true, `[${mode}] PlayerDeath handled`);++ } else {++ // hub/sandbox: no rounds; sandbox exercises Q-menu + entity spawn++ if (mode === "sandbox") {++ sender = p1;++ cl.ctx.net.Start("OV_Sandbox_Spawn"); cl.ctx.net.WriteString("crate"); cl.ctx.net.SendToServer();++ drain();++ ok(sv.logs.some((l) => /ov_fortwars_spawn crate/.test(l)), `[${mode}] client Q-menu spawn ran server command`);++ const r = sv.ctx.command.run(p1, "ent ov_bouncy_crate");++ ok(sv.ctx.ents.FindByClass("ov_bouncy_crate").length === 1, `[${mode}] !ent spawned scripted entity`);++ tp.tick(3); sv.ctx.gamemode.call("Think");++ const crate = sv.ctx.ents.FindByClass("ov_bouncy_crate")[0];++ ok(crate && crate.GetBounces() >= 1, `[${mode}] bouncy crate Think ran (bounces=${crate && crate.GetBounces()})`);++ drain();++ const clCrate = cl.ctx.ents.GetByIndex(crate.EntIndex());++ ok(clCrate && clCrate._r.dt.Bounces >= 1, `[${mode}] SENT DTVar replicated to client realm`);++ }++ ok(hudSeen.length >= 0, `[${mode}] no-round mode loaded cleanly`);++ }++}++++console.log(`\n${checks - failures}/${checks} checks passed`);++if (failures) { console.error(`${failures} FAILURES`); process.exit(1); }++console.log("[test-gmodjs] ALL PASS");
@@ -48,3 +48,5 @@ artifacts/
@@ -48,3 +48,5 @@ artifacts/
_deps/
_deps/
*.tmp
*.tmp
*.log
*.log
+game/openvibe.games/js/node_modules/
+game/openvibe.games/js/ov_downloads/
@@ -43,6 +43,15 @@ openvibe-source/
@@ -43,6 +43,15 @@ openvibe-source/
| Prop Hunt disguise | Implemented | `ov_prophunt_disguise`, `ov_prophunt_reset_disguise` |
| Prop Hunt disguise | Implemented | `ov_prophunt_disguise`, `ov_prophunt_reset_disguise` |
| Fort Wars placement | Implemented | `ov_fortwars_spawn` |
| Fort Wars placement | Implemented | `ov_fortwars_spawn` |
| VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |
| VScript game modes | Implemented prototypes | `game/openvibe.games/scripts/vscripts/ov_*.nut` |
+| GModJS framework | Implemented | `game/openvibe.games/js/core/*` — hook/net/entity/ents/player/team/file/concommand/loader (GMod API shapes in JS); design in `docs/GMODJS_ARCHITECTURE.md`, guide in `docs/OPENVIBE_JS_SCRIPTING.md` |
+| net library (JS) | Implemented | `js/core/net.js` — full GMod surface, pooling, chunked transport, per-player rate limits |
+| Entity system (JS + native) | Implemented | `js/core/entity.js`, `js/core/ents.js`, `sdk/openvibe/shared/ov_js_entity.cpp` (`OV.entCreate`/`OV.entCall`); scripted entities in `js/entities/` |
+| Client realm runtime | Implemented | `engine/openvibe-js-runtime/ov-runtime.js --realm client` + `sdk/openvibe/client/hl2mp/openvibe_js_client.cpp` |
+| Script exec commands | Implemented | `js_run`, `js_openscript`, `js_run_cl`, `js_openscript_cl` (gated by `sv_allowcsjs`), `ov_npm` |
+| AddCSJSFile + client sync | Implemented | `js/core/file.js` — manifest + net-based download into `js/ov_downloads/` |
+| npm hotloading | Implemented | runtime control server `POST /npm`, `ov_npm`, fs.watch reload; `js/package.json` + `js/vendor/` |
+| Unified GUI (launcher + in-game) | Implemented | `client/index.html` + `client/ui-sync.js` served to Electron (5173), backend (3000), and the CEF panel; routes incl. `console`, `options`, `hud` |
+| In-game console + options GUI | Implemented | Console route (SSE logs, js_run/exec/npm), Options route (`openvibe://convar` bridge), HUD overlay (`OV_HudState`) |
| Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes |
| Script packages API | Implemented | `GET /v1/scripts/packages`, `GET /v1/scripts/packages/:id`, `GET /v1/scripts/packages/:id/files`, admin upsert/enable/disable routes |
| JS round system | Implemented | Base round state machine; Prop Hunt, Deathrun, Fort Wars, Traitor Town each with teams, timers, win conditions |
| JS round system | Implemented | Base round state machine; Prop Hunt, Deathrun, Fort Wars, Traitor Town each with teams, timers, win conditions |
| Batch match rewards | Implemented | `POST /v1/matches/end/batch` — rewards multiple players in one idempotent call |
| Batch match rewards | Implemented | `POST /v1/matches/end/batch` — rewards multiple players in one idempotent call |
@@ -94,13 +103,32 @@ node tools/smoke-api.mjs
@@ -94,13 +103,32 @@ node tools/smoke-api.mjs
tools/dev-down.sh
tools/dev-down.sh
```
```
-## Verified On June 30, 2026
+GModJS framework (no C++ build needed):
-- Backend TypeScript build passed.
-- Backend Vitest suite passed: 13 tests.
-- Source SDK 2013 Linux64 build passed with OpenVibe C++ patch applied.
-- SRCDS smoke test passed for all five maps.
-- Full dev stack registered all five servers and returned valid travel targets for every mode.
+```bash
+cd ~/src/openvibe-source
+node tools/test-gmodjs.mjs # 131-check suite: hooks/net/entities/players/files/gamemodes
+node tools/smoke-js-core-node.mjs
+node tools/smoke-js-gamemodes.mjs
+node tools/smoke-runtime-ipc.mjs # both Node runtimes over real TCP IPC
+node tools/smoke-ui.mjs # unified GUI serving + route markers
+```
+
+## Verified On July 2, 2026
+
+- Backend TypeScript build passed; Vitest suite passed: 14 tests.
+- Source SDK 2013 Linux64 build passed with the OpenVibe C++ patch incl. the
+ GModJS additions (`ov_js_entity.cpp`, `js_run`/`js_openscript`/`ov_npm`,
+ client runtime events, `openvibe://cmd|convar` bridge, console spew tap).
+- SRCDS smoke passed for all five maps with the rebuilt server.so; the
+ embedded QuickJS boots the full GModJS core (net/entity/player/team/file/
+ concommand/loader + addons + npm require).
+- Live SRCDS console test: `js_run` evaluated in-engine, `js_openscript`
+ ran a script from `js/`, `ents.Create("ov_bouncy_crate")` spawned and was
+ queryable via `ents.FindByClass`.
+- GModJS suite: 131/131 checks; runtime IPC smoke and UI smoke passed.
+- npm hotloading verified live: `POST /npm install is-odd` into the running
+ runtime, then `require("is-odd")(7)` returned true without a restart.
## Production Readiness
## Production Readiness
@@ -514,6 +514,217 @@
@@ -514,6 +514,217 @@
}
}
.settings-input:focus { outline: none; border-color: var(--accent); }
.settings-input:focus { outline: none; border-color: var(--accent); }
+ /* ── Electron titlebar (launcher chrome, shell=electron only) ── */
+ .ov-titlebar {
+ display: none;
+ align-items: center;
+ height: 34px;
+ padding-left: 12px;
+ gap: 8px;
+ background: var(--bg-card);
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ -webkit-app-region: drag;
+ user-select: none;
+ }
+ html.ov-host-electron .ov-titlebar { display: flex; }
+ .tb-title { font-size: 12px; font-weight: 700; letter-spacing: 0.05em; color: var(--text-head); }
+ .tb-sub { font-size: 11px; color: var(--text-dim); }
+ .tb-controls { margin-left: auto; display: flex; -webkit-app-region: no-drag; }
+ .tb-btn {
+ width: 44px; height: 34px;
+ border: none; background: none;
+ color: var(--text-dim);
+ font-size: 13px;
+ cursor: pointer;
+ transition: background var(--transition), color var(--transition);
+ }
+ .tb-btn:hover { background: var(--bg-hover); color: var(--text-head); }
+ .tb-btn.close:hover { background: var(--red); color: #fff; }
+
+ /* Screens flex under the (optional) titlebar instead of fixed 100% height */
+ .screen.active { flex: 1; min-height: 0; height: auto; }
+
+ /* ── Console route ────────────────────────────────── */
+ :root { --console-font: 12px; --hud-scale: 1; }
+
+ .console-wrap { max-width: 1100px; display: flex; flex-direction: column; gap: 10px; }
+ .console-toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
+ .console-spacer { flex: 1; }
+
+ .pill {
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 10px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ color: var(--text-dim);
+ white-space: nowrap;
+ }
+ .pill.on { color: var(--green); border-color: rgba(76,175,80,0.4); background: rgba(76,175,80,0.1); }
+ .pill.off { color: var(--red); border-color: rgba(229,57,53,0.4); background: rgba(229,57,53,0.08); }
+ .pill.bridge { color: var(--orange, #ffb74d); border-color: rgba(255,183,77,0.4); background: rgba(255,183,77,0.08); }
+
+ .console-log {
+ background: #04080c;
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ padding: 10px 12px;
+ font-family: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
+ font-size: var(--console-font);
+ line-height: 1.45;
+ overflow-y: auto;
+ height: calc(100vh - 320px);
+ min-height: 220px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ }
+ .console-line { color: var(--text); }
+ .console-line.echo { color: var(--text-head); font-weight: 600; }
+ .console-line.ok { color: var(--green); }
+ .console-line.err { color: var(--red); }
+ .console-line .tag { color: var(--text-dim); }
+ .console-line .tag-server { color: var(--accent); }
+ .console-line .tag-client { color: var(--gold); }
+ .console-line .tag-engine { color: #a855f7; }
+
+ .console-input-row { display: flex; gap: 8px; align-items: center; }
+ .console-prompt { color: var(--accent); font-weight: 700; font-family: ui-monospace, Consolas, monospace; }
+ .console-input {
+ flex: 1;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text-head);
+ padding: 7px 10px;
+ font-family: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
+ font-size: var(--console-font);
+ }
+ .console-input:focus { outline: none; border-color: var(--accent); }
+ .console-select {
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ padding: 5px 8px;
+ font-size: 12px;
+ }
+ .btn-console {
+ padding: 5px 12px;
+ font-size: 12px;
+ font-weight: 600;
+ background: rgba(26,159,255,0.1);
+ border: 1px solid rgba(26,159,255,0.3);
+ border-radius: var(--radius);
+ color: var(--accent);
+ cursor: pointer;
+ transition: background var(--transition);
+ white-space: nowrap;
+ }
+ .btn-console:hover { background: rgba(26,159,255,0.2); }
+ .btn-console.toggled { background: rgba(245,166,35,0.15); border-color: rgba(245,166,35,0.4); color: var(--gold); }
+
+ /* ── Options route ────────────────────────────────── */
+ .options-section { max-width: 560px; margin-bottom: 28px; }
+ .options-title {
+ font-size: 13px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-dim);
+ padding-bottom: 6px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 4px;
+ }
+ .opt-note {
+ font-size: 12px;
+ color: var(--gold);
+ background: rgba(245,166,35,0.08);
+ border: 1px solid rgba(245,166,35,0.3);
+ border-radius: var(--radius);
+ padding: 8px 12px;
+ margin-top: 10px;
+ }
+ .settings-input.narrow { width: 120px; }
+ input[type="range"].settings-input { padding: 0; height: 24px; }
+ input[type="color"].settings-input { width: 60px; height: 30px; padding: 2px; }
+ .opt-inline { display: flex; gap: 8px; align-items: center; }
+
+ /* ── HUD overlay (#hud / ?hud=1) ──────────────────── */
+ html.ov-hud-mode, body.ov-hud-mode { background: transparent !important; }
+ #ov-hud {
+ display: none;
+ position: fixed;
+ inset: 0;
+ z-index: 5000;
+ pointer-events: none;
+ transform: scale(var(--hud-scale));
+ transform-origin: center center;
+ }
+ body.ov-hud-mode #ov-hud { display: block; }
+
+ .hud-banner { position: absolute; top: 24px; left: 50%; transform: translateX(-50%); text-align: center; }
+ .hud-round {
+ display: inline-block;
+ font-size: 20px;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ color: #fff;
+ text-shadow: 0 1px 4px rgba(0,0,0,0.8);
+ background: rgba(7,14,20,0.45);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: 8px;
+ padding: 6px 18px;
+ }
+ .hud-sub {
+ margin-top: 6px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #cfe3f3;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ }
+ .hud-health {
+ position: absolute;
+ left: 24px;
+ bottom: 24px;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+ .hud-health-num {
+ font-size: 34px;
+ font-weight: 800;
+ color: #fff;
+ text-shadow: 0 1px 4px rgba(0,0,0,0.8);
+ min-width: 60px;
+ }
+ .hud-health-bar {
+ width: 180px;
+ height: 10px;
+ background: rgba(7,14,20,0.55);
+ border: 1px solid rgba(255,255,255,0.15);
+ border-radius: 5px;
+ overflow: hidden;
+ }
+ .hud-health-fill {
+ height: 100%;
+ width: 100%;
+ background: linear-gradient(90deg, var(--green), #8bc34a);
+ transition: width 0.25s ease, background 0.25s ease;
+ }
+ .hud-health-fill.low { background: linear-gradient(90deg, var(--red), #ff7043); }
+ .hud-gamemode {
+ position: absolute;
+ right: 24px;
+ bottom: 24px;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: #9fc3de;
+ text-shadow: 0 1px 3px rgba(0,0,0,0.8);
+ }
+
/* scrollbar */
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--bg); }
::-webkit-scrollbar-track { background: var(--bg); }
@@ -523,6 +734,17 @@
@@ -523,6 +734,17 @@
</head>
</head>
<body>
<body>
+<!-- ── Electron launcher chrome (visible only when shell=electron) ────── -->
+<div class="ov-titlebar" id="ov-titlebar">
+ <span class="tb-title">OPENVIBE</span>
+ <span class="tb-sub">: Source</span>
+ <div class="tb-controls">
+ <button class="tb-btn" id="tb-min" title="Minimise" onclick="OVApp.winMinimize()">─</button>
+ <button class="tb-btn" id="tb-max" title="Maximise" onclick="OVApp.winMaximize()">□</button>
+ <button class="tb-btn close" id="tb-close" title="Close" onclick="OVApp.winClose()">✕</button>
+ </div>
+</div>
+
<!-- ───────────────────────────────────────────────────────── AUTH SCREEN -->
<!-- ───────────────────────────────────────────────────────── AUTH SCREEN -->
<div id="ov-auth" class="screen active">
<div id="ov-auth" class="screen active">
<div class="auth-card">
<div class="auth-card">
@@ -532,7 +754,7 @@
@@ -532,7 +754,7 @@
<div class="auth-divider"></div>
<div class="auth-divider"></div>
<div class="auth-label">Sign in to access all features and game modes.</div>
<div class="auth-label">Sign in to access all features and game modes.</div>
- <button id="btn-steam-auth" class="btn btn-steam" onclick="OV.steamAuth()">
+ <button id="btn-steam-auth" class="btn btn-steam" onclick="OVApp.steamAuth()">
<span class="steam-logo"></span>
<span class="steam-logo"></span>
Sign in with Steam
Sign in with Steam
</button>
</button>
@@ -541,7 +763,7 @@
@@ -541,7 +763,7 @@
<div id="devauth-section" style="display:none">
<div id="devauth-section" style="display:none">
<div class="auth-divider" style="margin-top:14px;"></div>
<div class="auth-divider" style="margin-top:14px;"></div>
- <button id="btn-dev-auth" class="btn btn-dev" onclick="OV.devAuth()">
+ <button id="btn-dev-auth" class="btn btn-dev" onclick="OVApp.devAuth()">
Dev / Local auth (no Steam)
Dev / Local auth (no Steam)
</button>
</button>
</div>
</div>
@@ -561,6 +783,8 @@
@@ -561,6 +783,8 @@
<button class="nav-tab" data-route="leaderboard">Leaderboard</button>
<button class="nav-tab" data-route="leaderboard">Leaderboard</button>
<button class="nav-tab" data-route="inventory">Inventory</button>
<button class="nav-tab" data-route="inventory">Inventory</button>
<button class="nav-tab" data-route="shop">Shop</button>
<button class="nav-tab" data-route="shop">Shop</button>
+ <button class="nav-tab" data-route="options">Options</button>
+ <button class="nav-tab" data-route="console">Console</button>
<button class="nav-tab" data-route="settings">Settings</button>
<button class="nav-tab" data-route="settings">Settings</button>
</nav>
</nav>
@@ -569,7 +793,7 @@
@@ -569,7 +793,7 @@
<span id="profile-name" class="profile-name">Player</span>
<span id="profile-name" class="profile-name">Player</span>
<span id="profile-currency" class="profile-currency">0 VC</span>
<span id="profile-currency" class="profile-currency">0 VC</span>
<span id="profile-xp" class="profile-xp">0 XP</span>
<span id="profile-xp" class="profile-xp">0 XP</span>
- <button class="btn-signout" onclick="OV.signOut()">Sign out</button>
+ <button class="btn-signout" onclick="OVApp.signOut()">Sign out</button>
</div>
</div>
</div>
</div>
@@ -580,31 +804,31 @@
@@ -580,31 +804,31 @@
<div id="route-portal" class="route active">
<div id="route-portal" class="route active">
<div class="section-header">Choose Your Game Mode</div>
<div class="section-header">Choose Your Game Mode</div>
<div class="modes-grid">
<div class="modes-grid">
- <div class="mode-card" onclick="OV.joinMode('hub')">
+ <div class="mode-card" onclick="OVApp.joinMode('hub')">
<div class="mode-icon">🏠</div>
<div class="mode-icon">🏠</div>
<div class="mode-title">Hub</div>
<div class="mode-title">Hub</div>
<div class="mode-desc">Central social hub. Meet other players, trade, and explore.</div>
<div class="mode-desc">Central social hub. Meet other players, trade, and explore.</div>
<span class="mode-badge">Open Now</span>
<span class="mode-badge">Open Now</span>
</div>
</div>
- <div class="mode-card" onclick="OV.joinMode('prophunt')">
+ <div class="mode-card" onclick="OVApp.joinMode('prophunt')">
<div class="mode-icon">🎭</div>
<div class="mode-icon">🎭</div>
<div class="mode-title">Prop Hunt</div>
<div class="mode-title">Prop Hunt</div>
<div class="mode-desc">Disguise as objects or hunt the hidden props before time runs out.</div>
<div class="mode-desc">Disguise as objects or hunt the hidden props before time runs out.</div>
<span class="mode-badge">Popular</span>
<span class="mode-badge">Popular</span>
</div>
</div>
- <div class="mode-card" onclick="OV.joinMode('deathrun')">
+ <div class="mode-card" onclick="OVApp.joinMode('deathrun')">
<div class="mode-icon">💀</div>
<div class="mode-icon">💀</div>
<div class="mode-title">Deathrun</div>
<div class="mode-title">Deathrun</div>
<div class="mode-desc">Race through deadly obstacle courses. One wrong step and you're gone.</div>
<div class="mode-desc">Race through deadly obstacle courses. One wrong step and you're gone.</div>
<span class="mode-badge">Fast-paced</span>
<span class="mode-badge">Fast-paced</span>
</div>
</div>
- <div class="mode-card" onclick="OV.joinMode('fortwars')">
+ <div class="mode-card" onclick="OVApp.joinMode('fortwars')">
<div class="mode-icon">🏰</div>
<div class="mode-icon">🏰</div>
<div class="mode-title">Fort Wars</div>
<div class="mode-title">Fort Wars</div>
<div class="mode-desc">Build your fortress and defend against enemy raids.</div>
<div class="mode-desc">Build your fortress and defend against enemy raids.</div>
<span class="mode-badge">Team Play</span>
<span class="mode-badge">Team Play</span>
</div>
</div>
- <div class="mode-card" onclick="OV.joinMode('traitortown')">
+ <div class="mode-card" onclick="OVApp.joinMode('traitortown')">
<div class="mode-icon">🕵️</div>
<div class="mode-icon">🕵️</div>
<div class="mode-title">Traitor Town</div>
<div class="mode-title">Traitor Town</div>
<div class="mode-desc">Trust no one. Identify and eliminate the hidden traitors.</div>
<div class="mode-desc">Trust no one. Identify and eliminate the hidden traitors.</div>
@@ -674,16 +898,174 @@
@@ -674,16 +898,174 @@
</div>
</div>
<div class="settings-row" style="border:none; margin-top:8px;">
<div class="settings-row" style="border:none; margin-top:8px;">
<span></span>
<span></span>
- <button class="btn btn-dev" style="width:auto; padding: 6px 18px;" onclick="OV.signOut()">
+ <button class="btn btn-dev" style="width:auto; padding: 6px 18px;" onclick="OVApp.signOut()">
Sign out
Sign out
</button>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
+ <!-- Options: game/UI settings (account auth stays on #settings) -->
+ <div id="route-options" class="route">
+ <div class="section-header">Options</div>
+
+ <div class="options-section">
+ <div class="options-title">Game</div>
+ <div id="options-engine-hint" class="opt-note" style="display:none">
+ Join a game to edit engine convars — these controls talk to the Source client in-game.
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Field of View</div>
+ <div class="settings-desc">fov_desired</div>
+ </div>
+ <input class="settings-input narrow opt-convar" data-convar="fov_desired" type="number"
+ min="54" max="120" step="1" onchange="OVApp.convarChanged(this)" />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Mouse Sensitivity</div>
+ <div class="settings-desc">sensitivity</div>
+ </div>
+ <input class="settings-input narrow opt-convar" data-convar="sensitivity" type="number"
+ min="0.1" max="20" step="0.05" onchange="OVApp.convarChanged(this)" />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Master Volume</div>
+ <div class="settings-desc">volume</div>
+ </div>
+ <input class="settings-input opt-convar" data-convar="volume" type="range"
+ min="0" max="1" step="0.05" onchange="OVApp.convarChanged(this)" />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Music Volume</div>
+ <div class="settings-desc">snd_musicvolume</div>
+ </div>
+ <input class="settings-input opt-convar" data-convar="snd_musicvolume" type="range"
+ min="0" max="1" step="0.05" onchange="OVApp.convarChanged(this)" />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Custom ov_* convar</div>
+ <div class="settings-desc">Allowlisted: names starting with ov_</div>
+ </div>
+ <div class="opt-inline">
+ <input id="opt-convar-name" class="settings-input narrow opt-convar" type="text" placeholder="ov_…" />
+ <input id="opt-convar-value" class="settings-input narrow opt-convar" type="text" placeholder="value" />
+ <button class="btn-console opt-convar" onclick="OVApp.setCustomConvar()">Set</button>
+ </div>
+ </div>
+ </div>
+
+ <div class="options-section">
+ <div class="options-title">UI</div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Theme Accent</div>
+ <div class="settings-desc">Accent color used across the UI</div>
+ </div>
+ <input id="opt-ui-accent" class="settings-input" type="color" value="#1a9fff"
+ onchange="OVApp.uiPrefChanged()" />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">HUD Scale</div>
+ <div class="settings-desc">Size of the in-game HUD overlay</div>
+ </div>
+ <input id="opt-ui-hudscale" class="settings-input" type="range" min="0.7" max="1.5" step="0.05" value="1"
+ onchange="OVApp.uiPrefChanged()" />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Console Font Size</div>
+ <div class="settings-desc">px, applies to the Console route</div>
+ </div>
+ <input id="opt-ui-consolefont" class="settings-input narrow" type="number" min="10" max="20" step="1" value="12"
+ onchange="OVApp.uiPrefChanged()" />
+ </div>
+ <div class="settings-row" style="border:none;">
+ <span></span>
+ <button class="btn-console" onclick="OVApp.resetUiPrefs()">Reset UI prefs</button>
+ </div>
+ </div>
+
+ <div class="options-section">
+ <div class="options-title">Account</div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">Display Name</div>
+ <div class="settings-desc">From your account (read-only)</div>
+ </div>
+ <input id="options-display-name" class="settings-input" type="text" readonly />
+ </div>
+ <div class="settings-row">
+ <div>
+ <div class="settings-label">API Endpoint</div>
+ <div class="settings-desc">Backend API URL (advanced)</div>
+ </div>
+ <input id="options-api-url" class="settings-input" type="text" onchange="OVApp.saveApiUrl(this.value)" />
+ </div>
+ <div class="settings-row" style="border:none;">
+ <span></span>
+ <button class="btn-console" onclick="OVApp.routeTo('settings')">Open account settings</button>
+ </div>
+ </div>
+
+ <div class="options-section">
+ <div class="options-title">Gamemode</div>
+ <div id="options-gamemode">
+ <div class="empty-state"><span class="spinner"></span> Checking active gamemode…</div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Console: unified JS/engine console -->
+ <div id="route-console" class="route">
+ <div class="section-header">Console</div>
+ <div class="console-wrap">
+ <div class="console-toolbar">
+ <select id="console-realm" class="console-select" title="Execution realm">
+ <option value="server">Server JS</option>
+ <option value="client">Client JS</option>
+ <option value="engine">Engine</option>
+ </select>
+ <span id="pill-server" class="pill off">server: off</span>
+ <span id="pill-client" class="pill off">client: off</span>
+ <span class="console-spacer"></span>
+ <button class="btn-console" onclick="OVApp.consoleQuick('ov_js_status')">ov_js_status</button>
+ <button class="btn-console" onclick="OVApp.consoleQuick('js_openscript')">js_openscript</button>
+ <button class="btn-console" onclick="OVApp.consoleQuick('ov_npm ls')">ov_npm ls</button>
+ <button id="console-pause" class="btn-console" onclick="OVApp.consolePause()">Pause scroll</button>
+ <button class="btn-console" onclick="OVApp.consoleClear()">Clear</button>
+ </div>
+ <div id="console-log" class="console-log" aria-live="polite"></div>
+ <div class="console-input-row">
+ <span class="console-prompt">›</span>
+ <input id="console-input" class="console-input" type="text" spellcheck="false" autocomplete="off"
+ placeholder="JS code — or ]engine_command — or npm install <pkg>" />
+ <button class="btn-console" onclick="OVApp.consoleSubmit()">Run</button>
+ </div>
+ </div>
+ </div>
+
</div><!-- /content -->
</div><!-- /content -->
</div><!-- /ov-main -->
</div><!-- /ov-main -->
+<!-- ─────────────────────────────────────────────── HUD OVERLAY (#hud / ?hud=1) -->
+<div id="ov-hud">
+ <div class="hud-banner">
+ <div id="hud-round" class="hud-round"> </div>
+ <div class="hud-sub"><span id="hud-team">—</span> · <span id="hud-timer">--:--</span></div>
+ </div>
+ <div class="hud-health">
+ <div id="hud-health-num" class="hud-health-num">100</div>
+ <div class="hud-health-bar"><div id="hud-health-fill" class="hud-health-fill"></div></div>
+ </div>
+ <div id="hud-gamemode" class="hud-gamemode"></div>
+</div>
+
<!-- Toast notification -->
<!-- Toast notification -->
<div id="ov-toast"></div>
<div id="ov-toast"></div>
@@ -691,12 +1073,27 @@
@@ -691,12 +1073,27 @@
(function () {
(function () {
'use strict';
'use strict';
- // ── Detect embedded Source engine context ─────────────────────────────
+ // ── Detect host: Electron launcher / embedded Source panel / browser ──
var params = new URLSearchParams(location.search);
var params = new URLSearchParams(location.search);
var embedded = params.get('embedded') === '1';
var embedded = params.get('embedded') === '1';
var shell = params.get('shell') || '';
var shell = params.get('shell') || '';
+ // In Electron, window.OV is the preload IPC bridge (contextBridge) and is
+ // already defined before this script runs. In-game / browser it is not,
+ // and we install our own window.OV below.
+ var electronOV = (window.OV && typeof window.OV.launchMode === 'function') ? window.OV : null;
+ var isElectron = shell === 'electron' || params.get('electron') === '1' || !!electronOV;
+ if (isElectron && !shell) shell = 'electron';
+ var hudMode = params.get('hud') === '1' || location.hash === '#hud';
var devMode = params.get('devauth') === '1' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
var devMode = params.get('devauth') === '1' || location.hostname === 'localhost' || location.hostname === '127.0.0.1';
- var apiBase = localStorage.getItem('ov_api_url') || (location.origin);
+ // Served from the dev UI server (:5173) or Electron, the backend lives on :3000.
+ var defaultApi = (isElectron || location.port === '5173') ? 'http://127.0.0.1:3000' : location.origin;
+ var apiBase = localStorage.getItem('ov_api_url') || defaultApi;
+
+ // Runtime control servers (GModJS ov-runtime.js) per realm.
+ var RT = {
+ server: 'http://127.0.0.1:41997',
+ client: 'http://127.0.0.1:41996'
+ };
// ── Internal state ────────────────────────────────────────────────────
// ── Internal state ────────────────────────────────────────────────────
var _session = null; // { sessionToken, steamId, displayName, provider }
var _session = null; // { sessionToken, steamId, displayName, provider }
@@ -825,11 +1222,14 @@
@@ -825,11 +1222,14 @@
var _routeLoaded = {};
var _routeLoaded = {};
function routeTo(routeName) {
function routeTo(routeName) {
- var valid = ['portal','servers','leaderboard','inventory','shop','settings'];
+ if (routeName === 'hud') { enterHud(); return; }
+ exitHud();
+
+ var valid = ['portal','servers','leaderboard','inventory','shop','settings','options','console'];
if (valid.indexOf(routeName) === -1) routeName = 'portal';
if (valid.indexOf(routeName) === -1) routeName = 'portal';
_currentRoute = routeName;
_currentRoute = routeName;
- location.replace('#' + routeName);
+ if (location.hash !== '#' + routeName) location.replace('#' + routeName);
document.querySelectorAll('.route').forEach(function (el) {
document.querySelectorAll('.route').forEach(function (el) {
el.classList.toggle('active', el.id === 'route-' + routeName);
el.classList.toggle('active', el.id === 'route-' + routeName);
@@ -838,11 +1238,12 @@
@@ -838,11 +1238,12 @@
el.classList.toggle('active', el.dataset.route === routeName);
el.classList.toggle('active', el.dataset.route === routeName);
});
});
- if (!_routeLoaded[routeName]) {
+ if (!_routeLoaded[routeName] || routeName === 'options') {
_routeLoaded[routeName] = true;
_routeLoaded[routeName] = true;
loadRoute(routeName);
loadRoute(routeName);
}
}
}
}
+ window.routeTo = routeTo; // used by ui-sync.js + ov_menu_* route commands
function loadRoute(route) {
function loadRoute(route) {
switch (route) {
switch (route) {
@@ -850,6 +1251,8 @@
@@ -850,6 +1251,8 @@
case 'leaderboard': loadLeaderboard(); break;
case 'leaderboard': loadLeaderboard(); break;
case 'inventory': loadInventory(); break;
case 'inventory': loadInventory(); break;
case 'shop': loadShop(); break;
case 'shop': loadShop(); break;
+ case 'console': consoleEnsure(); break;
+ case 'options': loadOptions(); break;
}
}
}
}
@@ -880,7 +1283,7 @@
@@ -880,7 +1283,7 @@
'<div class="server-name">' + escHtml(s.mapName) + '</div>' +
'<div class="server-name">' + escHtml(s.mapName) + '</div>' +
'<div class="server-players">' + s.playerCount + ' / ' + s.maxPlayers + '</div>' +
'<div class="server-players">' + s.playerCount + ' / ' + s.maxPlayers + '</div>' +
'<span class="server-state ' + stateClass + '">' + escHtml(s.state) + '</span>' +
'<span class="server-state ' + stateClass + '">' + escHtml(s.state) + '</span>' +
- (canJoin ? '<button class="btn-join-server" onclick="OV.joinMode(\'' + escHtml(s.mode) + '\')">Join</button>' : '<span></span>') +
+ (canJoin ? '<button class="btn-join-server" onclick="OVApp.joinMode(\'' + escHtml(s.mode) + '\')">Join</button>' : '<span></span>') +
'</div>';
'</div>';
}).join('');
}).join('');
}).catch(function () {
}).catch(function () {
@@ -964,7 +1367,7 @@
@@ -964,7 +1367,7 @@
'<div class="item-price">' + (item.price === 0 ? 'Free' : item.price + ' VC') + '</div>' +
'<div class="item-price">' + (item.price === 0 ? 'Free' : item.price + ' VC') + '</div>' +
(owned
(owned
? '<span class="item-owned">✓ Owned</span>'
? '<span class="item-owned">✓ Owned</span>'
- : '<button class="btn-buy" onclick="OV.buyItem(' + JSON.stringify(item.itemId) + ',' + item.price + ')" ' +
+ : '<button class="btn-buy" onclick="OVApp.buyItem(' + JSON.stringify(item.itemId) + ',' + item.price + ')" ' +
(!_session ? 'disabled' : '') + '>Buy</button>'
(!_session ? 'disabled' : '') + '>Buy</button>'
) +
) +
'</div>';
'</div>';
@@ -984,8 +1387,456 @@
@@ -984,8 +1387,456 @@
.replace(/'/g, ''');
.replace(/'/g, ''');
}
}
+ // ══════════════════════════════════════════════════════════════════════
+ // Console route (#console) — SSE log merge + JS eval + engine commands
+ // ══════════════════════════════════════════════════════════════════════
+ var _con = {
+ started: false,
+ paused: false,
+ history: [],
+ hIdx: 0,
+ es: {},
+ retry: { server: 0, client: 0 },
+ timers: {},
+ up: { server: false, client: false } // runtime control servers reachable?
+ };
+
+ // In-game the engine bridge (openvibe://cmd → js_run/js_run_cl, output back
+ // through the console spew tap) covers JS eval even with no Node runtimes.
+ function conHasEngineBridge() { return embedded && shell === 'source'; }
+
+ // state: 'live' (runtime ctrl server), 'bridge' (via engine), 'off'
+ function conPill(tag, state) {
+ _con.up[tag] = state === 'live';
+ if (state === 'off' && conHasEngineBridge()) state = 'bridge';
+ var el = document.getElementById('pill-' + tag);
+ if (el) {
+ el.className = 'pill ' + (state === 'live' ? 'on' : state === 'bridge' ? 'bridge' : 'off');
+ el.textContent = tag + ': ' + (state === 'live' ? 'runtime' : state === 'bridge' ? 'engine' : 'off');
+ }
+ }
+
+ function conAppend(tag, text, cls) {
+ var log = document.getElementById('console-log');
+ if (!log) return;
+ var line = document.createElement('div');
+ line.className = 'console-line' + (cls ? ' ' + cls : '');
+ var t = document.createElement('span');
+ t.className = 'tag tag-' + tag;
+ t.textContent = '[' + tag + '] ';
+ line.appendChild(t);
+ line.appendChild(document.createTextNode(String(text)));
+ log.appendChild(line);
+ while (log.childNodes.length > 2000) log.removeChild(log.firstChild);
+ if (!_con.paused) log.scrollTop = log.scrollHeight;
+ }
+
+ function conConnect(tag) {
+ if (!('EventSource' in window)) { conPill(tag, 'off'); return; }
+ try {
+ var es = new EventSource(RT[tag] + '/logs');
+ _con.es[tag] = es;
+ es.onopen = function () {
+ _con.retry[tag] = 0;
+ conPill(tag, 'live');
+ };
+ es.onmessage = function (ev) {
+ var text = ev.data;
+ try {
+ var obj = JSON.parse(ev.data);
+ if (obj && obj.text !== undefined) {
+ text = (obj.level && obj.level !== 'info' ? '[' + obj.level + '] ' : '') + obj.text;
+ }
+ } catch (e) { /* raw line */ }
+ conAppend(tag, text);
+ };
+ es.onerror = function () {
+ conPill(tag, 'off');
+ try { es.close(); } catch (e) {}
+ if (_con.es[tag] !== es) return; // superseded
+ var delay = Math.min(30000, 1000 * Math.pow(2, _con.retry[tag]++));
+ clearTimeout(_con.timers[tag]);
+ _con.timers[tag] = setTimeout(function () {
+ if (_con.started) conConnect(tag);
+ }, delay);
+ };
+ } catch (e) {
+ conPill(tag, 'off');
+ }
+ }
+
+ function consoleEnsure() {
+ var input = document.getElementById('console-input');
+ if (input) setTimeout(function () { input.focus(); }, 0);
+ if (_con.started) return;
+ _con.started = true;
+ conConnect('server');
+ conConnect('client');
+ if (conHasEngineBridge()) {
+ conAppend('engine', 'OpenVibe console ready. Engine output streams in below; Server/Client JS run via the runtimes when connected, otherwise through js_run / js_run_cl.', 'ok');
+ } else {
+ conAppend('engine', 'OpenVibe console ready — realms: Server JS (41997), Client JS (41996), Engine.', 'ok');
+ }
+
+ if (input) {
+ input.addEventListener('keydown', function (ev) {
+ if (ev.key === 'Enter') {
+ ev.preventDefault();
+ consoleSubmit();
+ } else if (ev.key === 'ArrowUp') {
+ ev.preventDefault();
+ if (_con.hIdx > 0) {
+ _con.hIdx -= 1;
+ input.value = _con.history[_con.hIdx] || '';
+ }
+ } else if (ev.key === 'ArrowDown') {
+ ev.preventDefault();
+ if (_con.hIdx < _con.history.length) {
+ _con.hIdx += 1;
+ input.value = _con.history[_con.hIdx] || '';
+ }
+ }
+ });
+ }
+ }
+
+ function conEval(realm, code) {
+ // Runtime control server offline? In-game we go through the engine bridge:
+ // js_run / js_run_cl — the result comes back through the console spew tap.
+ if (!_con.up[realm] && conHasEngineBridge()) {
+ var cmd = (realm === 'client' ? 'js_run_cl ' : 'js_run ') + code;
+ conAppend(realm, 'via engine: ' + cmd, 'echo');
+ location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);
+ return;
+ }
+ fetch(RT[realm] + '/eval', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ code: code })
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ if (res && res.error) {
+ var err = res.error;
+ conAppend(realm, 'error: ' + (typeof err === 'object' ? (err.message || JSON.stringify(err)) : err), 'err');
+ } else if (res && ('result' in res)) {
+ conAppend(realm, '= ' + (typeof res.result === 'string' ? res.result : JSON.stringify(res.result)), 'ok');
+ } else {
+ conAppend(realm, '= ' + JSON.stringify(res), 'ok');
+ }
+ }).catch(function (e) {
+ if (conHasEngineBridge()) {
+ var cmd2 = (realm === 'client' ? 'js_run_cl ' : 'js_run ') + code;
+ conAppend(realm, 'runtime unreachable — via engine: ' + cmd2, 'echo');
+ location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd2);
+ return;
+ }
+ conAppend(realm, 'eval failed (' + RT[realm] + '): ' + e.message + ' — start the runtime: node engine/openvibe-js-runtime/ov-runtime.js --realm ' + realm, 'err');
+ });
+ }
+
+ function conEngine(cmd) {
+ if (!cmd) return;
+ if (embedded && shell === 'source') {
+ // In-game: allowlisted engine commands via the openvibe:// bridge.
+ location.href = 'openvibe://cmd?c=' + encodeURIComponent(cmd);
+ conAppend('engine', 'sent: ' + cmd, 'ok');
+ return;
+ }
+ // Electron / browser: forward through the server runtime control server.
+ fetch(RT.server + '/exec', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ command: cmd })
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ conAppend('engine', typeof res === 'string' ? res : JSON.stringify(res), 'ok');
+ }).catch(function (e) {
+ conAppend('engine', 'exec failed (' + RT.server + '): ' + e.message, 'err');
+ });
+ }
+
+ function conNpm(rest) {
+ var args = rest.split(/\s+/).filter(Boolean);
+ if (!_con.up.server && conHasEngineBridge()) {
+ conAppend('server', 'via engine: ov_npm ' + args.join(' '), 'echo');
+ location.href = 'openvibe://cmd?c=' + encodeURIComponent('ov_npm ' + args.join(' '));
+ return;
+ }
+ conAppend('server', 'npm ' + args.join(' ') + ' …');
+ fetch(RT.server + '/npm', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ args: args })
+ }).then(function (r) { return r.json(); }).then(function (res) {
+ conAppend('server', typeof res === 'string' ? res : JSON.stringify(res), 'ok');
+ }).catch(function (e) {
+ conAppend('server', 'npm failed (' + RT.server + '): ' + e.message, 'err');
+ });
+ }
+
+ function consoleSubmit() {
+ var input = document.getElementById('console-input');
+ if (!input) return;
+ var line = input.value.trim();
+ if (!line) return;
+ _con.history.push(line);
+ _con.hIdx = _con.history.length;
+ input.value = '';
+
+ var realm = (document.getElementById('console-realm') || {}).value || 'server';
+
+ if (line.charAt(0) === ']') { // ]command → engine, any realm
+ conAppend('engine', '› ' + line, 'echo');
+ conEngine(line.slice(1).trim());
+ return;
+ }
+ if (/^npm(\s|$)/.test(line)) { // npm … → runtime /npm
+ conAppend('server', '› ' + line, 'echo');
+ conNpm(line.replace(/^npm\s*/, ''));
+ return;
+ }
+ if (realm === 'engine') {
+ conAppend('engine', '› ' + line, 'echo');
+ conEngine(line);
+ return;
+ }
+ conAppend(realm, '› ' + line, 'echo');
+ conEval(realm, line);
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // Options route (#options) — convars, UI prefs, account, gamemode
+ // ══════════════════════════════════════════════════════════════════════
+ var OPT_KEY = 'openvibe.options.v1';
+ var CONVAR_ALLOWLIST = ['fov_desired', 'sensitivity', 'volume', 'snd_musicvolume'];
+ var canEditConvars = embedded && shell === 'source';
+
+ function loadOptPrefs() {
+ try {
+ var raw = localStorage.getItem(OPT_KEY);
+ var data = raw ? JSON.parse(raw) : {};
+ return data && typeof data === 'object' ? data : {};
+ } catch (e) { return {}; }
+ }
+
+ function applyOptPrefs(prefs) {
+ prefs = prefs || {};
+ var rootStyle = document.documentElement.style;
+ if (prefs.accent) rootStyle.setProperty('--accent', prefs.accent);
+ if (prefs.hudScale) rootStyle.setProperty('--hud-scale', String(prefs.hudScale));
+ if (prefs.consoleFont) rootStyle.setProperty('--console-font', prefs.consoleFont + 'px');
+
+ var accentEl = document.getElementById('opt-ui-accent');
+ var scaleEl = document.getElementById('opt-ui-hudscale');
+ var fontEl = document.getElementById('opt-ui-consolefont');
+ if (accentEl && prefs.accent) accentEl.value = prefs.accent;
+ if (scaleEl && prefs.hudScale) scaleEl.value = prefs.hudScale;
+ if (fontEl && prefs.consoleFont) fontEl.value = prefs.consoleFont;
+ }
+
+ function saveOptPrefs(prefs, broadcast) {
+ try { localStorage.setItem(OPT_KEY, JSON.stringify(prefs)); } catch (e) {}
+ applyOptPrefs(prefs);
+ if (broadcast !== false && window.OpenVibeShell && window.OpenVibeShell.syncOptions) {
+ window.OpenVibeShell.syncOptions(prefs);
+ }
+ }
+
+ function isAllowedConvar(name) {
+ return CONVAR_ALLOWLIST.indexOf(name) !== -1 || /^ov_[a-z0-9_]+$/i.test(name);
+ }
+
+ function setConvar(name, value) {
+ if (!isAllowedConvar(name)) {
+ toast('Convar not allowlisted: ' + name, 'error');
+ return false;
+ }
+ if (!canEditConvars) {
+ toast('Join a game to edit engine convars.', 'info');
+ return false;
+ }
+ location.href = 'openvibe://convar?name=' + encodeURIComponent(name) +
+ '&value=' + encodeURIComponent(String(value));
+ return true;
+ }
+
+ function requestConvars() {
+ if (!canEditConvars) return;
+ // Non-navigating bridge request (like openvibe://ready); C++ answers by
+ // calling window.OV.onConvars({ name: value, … }).
+ var img = new Image();
+ img.src = 'openvibe://convar_get?names=' + encodeURIComponent(CONVAR_ALLOWLIST.join(','));
+ }
+
+ function loadOptions() {
+ // Engine section availability
+ var hint = document.getElementById('options-engine-hint');
+ if (hint) hint.style.display = canEditConvars ? 'none' : 'block';
+ document.querySelectorAll('.opt-convar').forEach(function (el) {
+ el.disabled = !canEditConvars;
+ });
+ requestConvars();
+
+ // UI prefs
+ applyOptPrefs(loadOptPrefs());
+
+ // Account mirror (reuses the same storage/session as #settings)
+ var apiEl = document.getElementById('options-api-url');
+ if (apiEl) apiEl.value = apiBase;
+ var nameEl = document.getElementById('options-display-name');
+ if (nameEl) nameEl.value = (_session && _session.displayName) || '';
+
+ // Gamemode manifest settings from the server runtime
+ loadGamemodeOptions();
+ }
+
+ function renderGamemodeForm(el, modeName, settings) {
+ var html = '<div class="settings-row" style="border-bottom:1px solid var(--border);">' +
+ '<div><div class="settings-label">Active gamemode</div></div>' +
+ '<span class="mode-badge">' + escHtml(modeName) + '</span></div>';
+
+ if (Array.isArray(settings) && settings.length) {
+ html += settings.map(function (s, i) {
+ var key = escHtml(s.name || s.key || ('setting_' + i));
+ var label = escHtml(s.label || s.title || s.name || ('Setting ' + i));
+ var type = String(s.type || 'text').toLowerCase();
+ var value = s.value != null ? s.value : (s.default != null ? s.default : '');
+ var input;
+ if (type === 'bool' || type === 'boolean' || type === 'checkbox') {
+ input = '<input class="settings-input narrow gm-setting" data-gm="' + key + '" type="checkbox" ' + (value ? 'checked' : '') + ' />';
+ } else if (type === 'number' || type === 'int' || type === 'float') {
+ input = '<input class="settings-input narrow gm-setting" data-gm="' + key + '" type="number" value="' + escHtml(String(value)) + '"' +
+ (s.min != null ? ' min="' + Number(s.min) + '"' : '') +
+ (s.max != null ? ' max="' + Number(s.max) + '"' : '') + ' />';
+ } else if (type === 'select' && Array.isArray(s.options)) {
+ input = '<select class="settings-input narrow gm-setting" data-gm="' + key + '">' +
+ s.options.map(function (o) {
+ var ov = typeof o === 'object' ? (o.value != null ? o.value : o.label) : o;
+ var ol = typeof o === 'object' ? (o.label != null ? o.label : o.value) : o;
+ return '<option value="' + escHtml(String(ov)) + '"' + (String(ov) === String(value) ? ' selected' : '') + '>' + escHtml(String(ol)) + '</option>';
+ }).join('') + '</select>';
+ } else {
+ input = '<input class="settings-input gm-setting" data-gm="' + key + '" type="text" value="' + escHtml(String(value)) + '" />';
+ }
+ return '<div class="settings-row"><div><div class="settings-label">' + label + '</div>' +
+ (s.description ? '<div class="settings-desc">' + escHtml(s.description) + '</div>' : '') +
+ '</div>' + input + '</div>';
+ }).join('');
+ } else {
+ html += '<div class="empty-state">This gamemode exposes no settings.</div>';
+ }
+ el.innerHTML = html;
+
+ el.querySelectorAll('.gm-setting').forEach(function (input) {
+ input.addEventListener('change', function () {
+ var name = input.dataset.gm;
+ var value = input.type === 'checkbox' ? (input.checked ? 1 : 0) : input.value;
+ conEngine('ov_gm_set ' + name + ' ' + value);
+ toast('Gamemode setting sent: ' + name, 'success');
+ });
+ });
+ }
+
+ function loadGamemodeOptions() {
+ var el = document.getElementById('options-gamemode');
+ if (!el) return;
+ fetch(RT.server + '/state').then(function (r) { return r.json(); }).then(function (st) {
+ st = st || {};
+ var gm = st.gamemode != null ? st.gamemode : (st.mode != null ? st.mode : (st.state && st.state.gamemode));
+ if (!gm) {
+ el.innerHTML = '<div class="empty-state">No active gamemode reported by the runtime.</div>';
+ return;
+ }
+ var modeName = typeof gm === 'object' ? (gm.title || gm.name || 'unknown') : String(gm);
+ var settings = (typeof gm === 'object' && Array.isArray(gm.settings)) ? gm.settings
+ : (Array.isArray(st.settings) ? st.settings
+ : (st.manifest && Array.isArray(st.manifest.settings) ? st.manifest.settings : null));
+ renderGamemodeForm(el, modeName, settings);
+ }).catch(function () {
+ el.innerHTML = '<div class="empty-state">Server JS runtime offline (' + escHtml(RT.server) +
+ ') — start a game server to configure the active gamemode.</div>';
+ });
+ }
+
+ // ══════════════════════════════════════════════════════════════════════
+ // HUD overlay (#hud / ?hud=1) — translucent in-game overlay
+ // ══════════════════════════════════════════════════════════════════════
+ var _hudActive = false;
+ var _hudPollTimer = null;
+
+ function fmtTimer(v) {
+ if (v == null || v === '') return '--:--';
+ var n = Number(v);
+ if (isNaN(n)) return String(v);
+ n = Math.max(0, Math.floor(n));
+ var m = Math.floor(n / 60);
+ var s = n % 60;
+ return m + ':' + (s < 10 ? '0' : '') + s;
+ }
+
+ function applyHudState(st) {
+ if (!st || typeof st !== 'object') return;
+ var round = st.round != null ? st.round : (st.roundState != null ? st.roundState : st.state);
+ var team = st.team != null ? st.team : (st.teamName != null ? st.teamName : null);
+ var timer = st.timer != null ? st.timer : (st.timeLeft != null ? st.timeLeft : st.time);
+ var hp = st.health != null ? st.health : st.hp;
+ var mode = st.gamemode != null ? st.gamemode : st.mode;
+
+ var el;
+ if (round != null && (el = document.getElementById('hud-round'))) el.textContent = String(round);
+ if (team != null && (el = document.getElementById('hud-team'))) el.textContent = String(team);
+ if (timer != null && (el = document.getElementById('hud-timer'))) el.textContent = fmtTimer(timer);
+ if (mode != null && (el = document.getElementById('hud-gamemode'))) {
+ el.textContent = typeof mode === 'object' ? (mode.title || mode.name || '') : String(mode);
+ }
+ if (hp != null) {
+ var n = Math.max(0, Math.round(Number(hp) || 0));
+ var num = document.getElementById('hud-health-num');
+ var fill = document.getElementById('hud-health-fill');
+ if (num) num.textContent = String(n);
+ if (fill) {
+ fill.style.width = Math.min(100, n) + '%';
+ fill.className = 'hud-health-fill' + (n <= 25 ? ' low' : '');
+ }
+ }
+ }
+
+ function hudPollOnce() {
+ // Client runtime first (has LocalPlayer data), server runtime as fallback.
+ fetch(RT.client + '/state')
+ .then(function (r) { return r.json(); })
+ .then(applyHudState)
+ .catch(function () {
+ fetch(RT.server + '/state')
+ .then(function (r) { return r.json(); })
+ .then(applyHudState)
+ .catch(function () {});
+ });
+ }
+
+ function enterHud() {
+ if (_hudActive) return;
+ _hudActive = true;
+ document.documentElement.classList.add('ov-hud-mode');
+ document.body.classList.add('ov-hud-mode');
+ document.getElementById('ov-auth').classList.remove('active');
+ document.getElementById('ov-main').classList.remove('active');
+ if (location.hash !== '#hud') location.replace('#hud');
+ hudPollOnce();
+ _hudPollTimer = setInterval(hudPollOnce, 2000);
+ }
+
+ function exitHud() {
+ if (!_hudActive) return;
+ _hudActive = false;
+ document.documentElement.classList.remove('ov-hud-mode');
+ document.body.classList.remove('ov-hud-mode');
+ clearInterval(_hudPollTimer);
+ _hudPollTimer = null;
+ if (_session) document.getElementById('ov-main').classList.add('active');
+ else document.getElementById('ov-auth').classList.add('active');
+ }
+
// ── Public OV API (called by C++ and by page itself) ──────────────────
// ── Public OV API (called by C++ and by page itself) ──────────────────
- window.OV = {
+ var OVApp = {
// Called by C++ after GetTicketForWebApi + POST /v1/auth/steam succeed.
// Called by C++ after GetTicketForWebApi + POST /v1/auth/steam succeed.
// data: { authenticated, sessionToken, steamId, displayName, error? }
// data: { authenticated, sessionToken, steamId, displayName, error? }
@@ -1056,8 +1907,18 @@
@@ -1056,8 +1907,18 @@
});
});
},
},
- // Join a game mode — uses openvibe:// bridge in-game, API otherwise.
+ // Join a game mode — Electron launches the game, in-game uses the
+ // openvibe:// bridge, plain browser just explains.
joinMode: function (mode) {
joinMode: function (mode) {
+ if (electronOV && electronOV.launchMode) {
+ toast('Launching ' + mode + '…', 'info', 4000);
+ Promise.resolve(electronOV.launchMode(mode)).then(function (ok) {
+ if (ok === false) toast('Launch failed — game binary not found. Build the SDK first.', 'error', 5000);
+ }).catch(function (e) {
+ toast('Launch failed: ' + e.message, 'error', 5000);
+ });
+ return;
+ }
if (embedded && shell === 'source') {
if (embedded && shell === 'source') {
location.href = 'openvibe://join?mode=' + encodeURIComponent(mode);
location.href = 'openvibe://join?mode=' + encodeURIComponent(mode);
} else {
} else {
@@ -1104,11 +1965,170 @@
@@ -1104,11 +1965,170 @@
// Called by C++ when the ready bridge is sent (openvibe://ready).
// Called by C++ when the ready bridge is sent (openvibe://ready).
// C++ uses this to inject stored session tokens on page reload.
// C++ uses this to inject stored session tokens on page reload.
- onReady: function () {}
+ onReady: function () {},
+
+ // ── Navigation / window chrome ────────────────────────────────────
+ routeTo: routeTo,
+ winMinimize: function () { if (electronOV && electronOV.minimize) electronOV.minimize(); },
+ winMaximize: function () { if (electronOV && electronOV.maximize) electronOV.maximize(); },
+ winClose: function () {
+ if (electronOV && electronOV.close) electronOV.close();
+ else location.href = 'openvibe://close';
+ },
+
+ // ── Console ───────────────────────────────────────────────────────
+ consoleSubmit: consoleSubmit,
+ consoleClear: function () {
+ var log = document.getElementById('console-log');
+ if (log) log.innerHTML = '';
+ },
+ consolePause: function () {
+ _con.paused = !_con.paused;
+ var btn = document.getElementById('console-pause');
+ if (btn) {
+ btn.classList.toggle('toggled', _con.paused);
+ btn.textContent = _con.paused ? 'Resume scroll' : 'Pause scroll';
+ }
+ if (!_con.paused) {
+ var log = document.getElementById('console-log');
+ if (log) log.scrollTop = log.scrollHeight;
+ }
+ },
+ consoleQuick: function (cmd) {
+ consoleEnsure();
+ conAppend('engine', '› ' + cmd, 'echo');
+ conEngine(cmd);
+ },
+
+ // Called by the C++ console spew tap (vgui_openvibe_menu.cpp) to push
+ // engine console output into the console view. Accepts a string or an
+ // array of lines.
+ onConsoleLine: function (lines) {
+ if (lines == null) return;
+ var arr = Array.isArray(lines) ? lines : [lines];
+ for (var i = 0; i < arr.length; i++) conAppend('engine', arr[i]);
+ },
+
+ // ── Options ───────────────────────────────────────────────────────
+ // Called by C++ with current convar values, requested via
+ // openvibe://convar_get?names=… → { fov_desired: "90", … }
+ onConvars: function (obj) {
+ if (!obj || typeof obj !== 'object') return;
+ document.querySelectorAll('.opt-convar[data-convar]').forEach(function (el) {
+ var name = el.dataset.convar;
+ if (name in obj) el.value = obj[name];
+ });
+ },
+
+ convarChanged: function (el) {
+ if (!el || !el.dataset.convar) return;
+ if (setConvar(el.dataset.convar, el.value)) {
+ toast(el.dataset.convar + ' = ' + el.value, 'success');
+ }
+ },
+
+ setCustomConvar: function () {
+ var nameEl = document.getElementById('opt-convar-name');
+ var valEl = document.getElementById('opt-convar-value');
+ var name = nameEl ? nameEl.value.trim() : '';
+ if (!name) return;
+ if (!/^ov_[a-z0-9_]+$/i.test(name)) {
+ toast('Only ov_* convars are allowed here.', 'error');
+ return;
+ }
+ if (setConvar(name, valEl ? valEl.value : '')) {
+ toast(name + ' = ' + (valEl ? valEl.value : ''), 'success');
+ }
+ },
+
+ uiPrefChanged: function () {
+ var prefs = loadOptPrefs();
+ var accentEl = document.getElementById('opt-ui-accent');
+ var scaleEl = document.getElementById('opt-ui-hudscale');
+ var fontEl = document.getElementById('opt-ui-consolefont');
+ if (accentEl) prefs.accent = accentEl.value;
+ if (scaleEl) prefs.hudScale = Number(scaleEl.value) || 1;
+ if (fontEl) prefs.consoleFont = Math.min(20, Math.max(10, Number(fontEl.value) || 12));
+ saveOptPrefs(prefs);
+ toast('UI preferences saved.', 'success');
+ },
+
+ resetUiPrefs: function () {
+ saveOptPrefs({ accent: '#1a9fff', hudScale: 1, consoleFont: 12 });
+ toast('UI preferences reset.', 'success');
+ },
+
+ // Applied when the other host broadcasts option changes (ui-sync.js).
+ applyOptions: function (prefs, fromRemote) {
+ if (!prefs || typeof prefs !== 'object') return;
+ if (fromRemote) {
+ try { localStorage.setItem(OPT_KEY, JSON.stringify(prefs)); } catch (e) {}
+ applyOptPrefs(prefs);
+ } else {
+ saveOptPrefs(prefs);
+ }
+ },
+
+ saveApiUrl: function (value) {
+ if (!value) return;
+ apiBase = String(value).replace(/\/+$/, '');
+ try { localStorage.setItem('ov_api_url', apiBase); } catch (e) {}
+ var apiInput = document.getElementById('settings-api-url');
+ if (apiInput) apiInput.value = apiBase;
+ var optInput = document.getElementById('options-api-url');
+ if (optInput) optInput.value = apiBase;
+ toast('API endpoint saved.', 'success');
+ },
+
+ // ── HUD ───────────────────────────────────────────────────────────
+ // Called via ov_menu_js push (client runtime → C++ → this page) with
+ // { round, team, timer, health, gamemode }. Poll of /state is fallback.
+ onHudState: function (obj) {
+ applyHudState(obj);
+ }
};
};
+ // Expose the app API. In-game / browser: window.OV is this object and C++
+ // calls window.OV.onSteamAuthResult / onConsoleLine / onConvars /
+ // onHudState on it. In Electron, window.OV is the preload IPC bridge
+ // (read-only contextBridge) and must be preserved — the app is reachable
+ // as window.OVApp in both hosts.
+ window.OVApp = OVApp;
+ if (!electronOV) {
+ window.OV = OVApp;
+ }
+
// ── Startup: check stored session ─────────────────────────────────────
// ── Startup: check stored session ─────────────────────────────────────
function init() {
function init() {
+ // UI prefs (accent / hud scale / console font) apply in every host.
+ applyOptPrefs(loadOptPrefs());
+
+ // Electron launcher wiring (preload IPC bridge).
+ if (electronOV) {
+ if (electronOV.onRoute) electronOV.onRoute(function (route) { routeTo(route); });
+ if (electronOV.onGameStart) electronOV.onGameStart(function (pid) {
+ toast('Game launched' + (pid ? ' (pid ' + pid + ')' : '') + '.', 'success');
+ });
+ if (electronOV.onGameExit) electronOV.onGameExit(function (code) {
+ toast('Game exited (code ' + code + ').', 'info');
+ });
+ if (electronOV.onLaunchState) electronOV.onLaunchState(function (state) {
+ if (state && state.message && state.phase && state.phase !== 'idle') {
+ toast(state.message, 'info', 4000);
+ }
+ });
+ }
+
+ // HUD overlay mode: no auth, no chrome — just the translucent overlay.
+ if (hudMode) {
+ enterHud();
+ if (embedded && shell === 'source') {
+ var hudReady = new Image();
+ hudReady.src = 'openvibe://ready';
+ }
+ return;
+ }
+
if (devMode) {
if (devMode) {
var devSection = document.getElementById('devauth-section');
var devSection = document.getElementById('devauth-section');
if (devSection) devSection.style.display = 'block';
if (devSection) devSection.style.display = 'block';
@@ -1144,5 +2164,7 @@
@@ -1144,5 +2164,7 @@
})();
})();
</script>
</script>
+<!-- Route/state sync shared with the other host (localStorage + BroadcastChannel) -->
+<script src="ui-sync.js"></script>
</body>
</body>
</html>
</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,253 @@
@@ -0,0 +1,253 @@
+/*
+ * OpenVibe unified shell sync (client/ui-sync.js).
+ *
+ * Runs in every host of the unified app:
+ * - Electron launcher Chromium (?electron=1&shell=electron)
+ * - Source/VGUI embedded panel (?embedded=1&shell=source)
+ * - Plain browser (backend :3000 /client/, or dev server :5173)
+ *
+ * Keeps the route/state contract identical across hosts:
+ * - location.hash
+ * - localStorage["openvibe.ui.state.v1"]
+ * - BroadcastChannel("openvibe-ui-sync-v1")
+ *
+ * Adapted from launcher/openvibe-ui-sync.js with the extended route set
+ * (options, console, hud) for the GModJS platform.
+ */
+(() => {
+ 'use strict';
+
+ const STORAGE_KEY = 'openvibe.ui.state.v1';
+ const CHANNEL_NAME = 'openvibe-ui-sync-v1';
+ const ROUTES = new Set([
+ 'portal', 'servers', 'leaderboard', 'inventory', 'shop',
+ 'settings', 'options', 'console', 'hud',
+ ]);
+
+ const url = new URL(window.location.href);
+ // window.OV exists in BOTH hosts (preload bridge in Electron, inline app
+ // object in-game), so detect Electron by query params / preload-only API.
+ const isElectron =
+ url.searchParams.get('shell') === 'electron' ||
+ url.searchParams.get('electron') === '1' ||
+ !!(window.OV && typeof window.OV.launchMode === 'function' && typeof window.OV.minimize === 'function');
+ const isEmbedded =
+ url.searchParams.get('embedded') === '1' ||
+ url.searchParams.get('shell') === 'source';
+
+ let channel = null;
+ try {
+ channel = 'BroadcastChannel' in window ? new BroadcastChannel(CHANNEL_NAME) : null;
+ } catch {
+ channel = null;
+ }
+
+ function readState() {
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) return {};
+ const data = JSON.parse(raw);
+ return data && typeof data === 'object' ? data : {};
+ } catch {
+ return {};
+ }
+ }
+
+ function writeState(patch, broadcast = true) {
+ const next = {
+ ...readState(),
+ ...patch,
+ updatedAt: Date.now(),
+ host: isElectron ? 'electron' : 'source'
+ };
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
+ } catch {}
+
+ if (broadcast && channel) {
+ try {
+ channel.postMessage({ type: 'state', state: next });
+ } catch {}
+ }
+
+ return next;
+ }
+
+ function routeFromHash() {
+ const hash = (window.location.hash || '').replace(/^#\/?/, '').trim();
+ const first = hash.split(/[/?&]/)[0] || '';
+ return ROUTES.has(first) ? first : null;
+ }
+
+ function routeFromQuery() {
+ if (url.searchParams.get('hud') === '1') return 'hud';
+ const route = url.searchParams.get('route');
+ return ROUTES.has(route) ? route : null;
+ }
+
+ function currentRoute() {
+ return routeFromHash() || routeFromQuery() || readState().route || 'portal';
+ }
+
+ function applyRouteToDom(route) {
+ // Unified app exposes routeTo (client/index.html); prefer it.
+ if (typeof window.routeTo === 'function') {
+ try { window.routeTo(route); return; } catch {}
+ }
+ if (typeof window.setTab === 'function') {
+ try { window.setTab(route); return; } catch {}
+ }
+ // Generic fallback for both markup dialects.
+ document.querySelectorAll('.nav-tab').forEach((b) => {
+ b.classList.toggle('active', b.dataset.route === route);
+ });
+ document.querySelectorAll('.route').forEach((p) => {
+ p.classList.toggle('active', p.id === `route-${route}`);
+ });
+ document.querySelectorAll('.nav-btn').forEach((b) => {
+ b.classList.toggle('active', b.dataset.tab === route);
+ });
+ document.querySelectorAll('.tab-panel').forEach((p) => {
+ p.classList.toggle('active', p.id === `tab-${route}`);
+ });
+ }
+
+ function setRoute(route, opts = {}) {
+ if (!ROUTES.has(route)) route = 'portal';
+
+ applyRouteToDom(route);
+
+ if (!opts.silentHash && window.location.hash !== `#${route}`) {
+ history.replaceState(null, '', `${window.location.pathname}${window.location.search}#${route}`);
+ }
+
+ writeState({ route, embedded: isEmbedded, electron: isElectron }, !opts.fromRemote);
+
+ const evt = new CustomEvent('openvibe:route', { detail: { route, host: isElectron ? 'electron' : 'source' } });
+ window.dispatchEvent(evt);
+ }
+
+ function installKeyboard() {
+ window.addEventListener('keydown', (ev) => {
+ const key = (ev.key || '').toLowerCase();
+ const target = ev.target;
+ const typing = !!(target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable));
+
+ if (key === 'escape' && isEmbedded) {
+ ev.preventDefault();
+ window.location.href = 'openvibe://close';
+ return;
+ }
+
+ if (ev.ctrlKey && key === 'r') {
+ ev.preventDefault();
+ if (isEmbedded) window.location.href = 'openvibe://reload';
+ else window.location.reload();
+ return;
+ }
+
+ // ` (backquote) or F10 toggles the console route.
+ if (key === '`' || key === 'f10') {
+ if (key === '`' && typing) return; // let people type backticks
+ ev.preventDefault();
+ setRoute(currentRoute() === 'console' ? 'portal' : 'console');
+ return;
+ }
+
+ if (typing) return;
+
+ if (key === 'f1') {
+ ev.preventDefault();
+ setRoute('portal');
+ }
+
+ if (key === 'f2') {
+ ev.preventDefault();
+ setRoute('servers');
+ }
+ });
+ }
+
+ function installBridge() {
+ window.OpenVibeShell = {
+ isElectron,
+ isEmbedded,
+ routes: Array.from(ROUTES),
+ getState: readState,
+ setRoute,
+ // Sync UI prefs (openvibe.options.v1) to the other host.
+ syncOptions(options) {
+ if (!options || typeof options !== 'object') return;
+ writeState({ options });
+ },
+ openMode(mode) {
+ if (isElectron && window.OV?.launchMode) {
+ return window.OV.launchMode(mode);
+ }
+ window.location.href = `openvibe://join?mode=${encodeURIComponent(mode)}`;
+ return true;
+ },
+ close() {
+ if (isElectron && window.OV?.close) window.OV.close();
+ else window.location.href = 'openvibe://close';
+ },
+ reload() {
+ if (isEmbedded) window.location.href = 'openvibe://reload';
+ else window.location.reload();
+ }
+ };
+ }
+
+ function applyRemoteState(state) {
+ if (!state || typeof state !== 'object') return;
+ if (state.options && window.OVApp && typeof window.OVApp.applyOptions === 'function') {
+ try { window.OVApp.applyOptions(state.options, true); } catch {}
+ }
+ // Never yank the other host into HUD-overlay mode remotely.
+ if (ROUTES.has(state.route) && state.route !== 'hud') {
+ setRoute(state.route, { fromRemote: true, silentHash: false });
+ }
+ }
+
+ function installRemoteState() {
+ if (channel) {
+ channel.onmessage = (ev) => {
+ const msg = ev.data || {};
+ if (msg.type !== 'state' || !msg.state) return;
+ if (msg.state.host === (isElectron ? 'electron' : 'source')) return;
+ applyRemoteState(msg.state);
+ };
+ }
+
+ window.addEventListener('storage', (ev) => {
+ if (ev.key !== STORAGE_KEY || !ev.newValue) return;
+ try {
+ applyRemoteState(JSON.parse(ev.newValue));
+ } catch {}
+ });
+ }
+
+ function boot() {
+ document.documentElement.classList.toggle('ov-host-electron', isElectron);
+ document.documentElement.classList.toggle('ov-host-source', !isElectron);
+ document.documentElement.classList.toggle('ov-embedded', isEmbedded);
+
+ installBridge();
+ installKeyboard();
+ installRemoteState();
+
+ setRoute(currentRoute(), { silentHash: false });
+
+ window.addEventListener('hashchange', () => {
+ setRoute(currentRoute(), { silentHash: true });
+ });
+
+ console.log(`[OpenVibeShell] ready host=${isElectron ? 'electron' : 'source'} embedded=${isEmbedded} route=${currentRoute()}`);
+ }
+
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', boot);
+ } else {
+ boot();
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+# OpenVibe GModJS Architecture
+
+Goal: a full GMod-style scripting platform for OpenVibe: Source — GMod Lua API
+shapes, implemented in JavaScript, with npm integration, hotloading, and an
+HTML/JS/CSS GUI that is shared between the desktop launcher and the in-game
+menu.
+
+This document is the blueprint for the framework in `game/openvibe.games/js/`
+plus its C++ bridge (`sdk/openvibe/`) and the unified web GUI (`client/`).
+
+## Realms
+
+Mirrors GMod's states (https://wiki.facepunch.com/gmod/States):
+
+| Realm | Global | Where it runs |
+| ------ | ------------- | ------------- |
+| server | `SERVER=true` | QuickJS embedded in server DLL (`ov_js_backend embedded`) or `ov-runtime.js --realm server` (`node` backend, TCP 41999) |
+| client | `CLIENT=true` | `ov-runtime.js --realm client` (TCP 41998; embedded client QuickJS was abandoned — clang-cl crash) |
+| menu | `MENU=true` | The HTML GUI itself (Electron renderer / in-game CEF panel) |
+
+Realm globals are published by `js/core/realm.js` from `OV.isServer()`.
+Client<->server talk only via the `net` library. The menu talks to the game
+via `openvibe://` bridge actions and to the runtimes via the runtime control
+HTTP server (see Console).
+
+## File layout (GMod-parity)
+
+```text
+game/openvibe.games/js/
+ core/ hook, gamemode, net, timer, command, concommand, module,
+ addon, realm, file, entity, ents, scripted_ents, nwvars,
+ player_ext, util
+ autorun/ *.js run on both realms (auto-networked to clients)
+ autorun/server/ *.js server only
+ autorun/client/ *.js client only (auto-networked)
+ entities/<class>.js single-file scripted entity (shared; guard with SERVER/CLIENT)
+ entities/<class>/{shared,init,cl_init}.js folder-form scripted entity
+ gamemodes/<name>/manifest.json GMod <name>.txt equivalent (base, title, settings[])
+ gamemodes/<name>/shared.js both realms
+ gamemodes/<name>/server.js server entry (GMod init.lua)
+ gamemodes/<name>/client.js client entry (GMod cl_init.lua, auto-networked)
+ node_modules/ npm packages (require()-able from any script)
+addons/<name>/{addon.json,shared.js,server.js,client.js,entities/,autorun/}
+```
+
+Loading order (per realm, GMod Lua_Loading_Order-faithful):
+core -> autorun (alphabetical; shared then realm dir) -> base gamemode ->
+active gamemode (shared -> realm entry) -> scripted entities -> addons ->
+`Initialize` -> `InitPostEntity`.
+
+## Hook library (js/core/hook.js)
+
+GMod semantics (Hook_Library_Usage):
+- `hook.Add(event, id, fn)` — id is a string OR an object with `IsValid()`;
+ invalid-object hooks are auto-removed at call time; object id is prepended
+ as first arg (self).
+- First non-`undefined` return short-circuits (false counts, undefined does
+ not); hooks run before the `GAMEMODE` method; `hook.Run` = `hook.Call`
+ with the active gamemode table.
+
+## net library (js/core/net.js)
+
+Full GMod surface: `Start(name, unreliable)`, writers/readers for
+Bit/Bool/Int/UInt/UInt64/Float/Double/String/Data/Vector/Normal/Angle/Color/
+Entity/Player/Table/Type, `Send/Broadcast/SendOmit/SendPVS/SendPAS/
+SendToServer/Abort`, `BytesWritten/BytesLeft`, `Receive` (single handler,
+name lowercased), `util.AddNetworkString/NetworkStringToID/IDToString`.
+
+Faithful behaviors: 64KB payload cap enforced, read-past-end returns
+defaults, server receivers get `(len, ply)` with authoritative sender,
+per-player per-message rate limiting (`net.SetRateLimit`), names must be
+pooled server-side before `Start`.
+
+Transport (unchanged wire): serialized typed-field JSON, base64. S->C =
+`OV.netEmit` -> `OVNet` usermessage -> client bridge -> client runtime.
+C->S = `ov_net <name> <payload>` forwarded command -> `OVNetReceive` hook.
+Payloads over the usermessage limit are chunked (`OVNetChunk`).
+
+## Entities (js/core/entity.js, ents.js, scripted_ents.js, nwvars.js)
+
+- `Entity` wrapper over `{entIndex, serial}` handles; `NULL` singleton;
+ `IsValid()`; `Remove()` deferred to end of tick firing `CallOnRemove` then
+ `OnRemove`. Server methods call native bindings (`OV.ent*`); everything
+ degrades gracefully in the Node harness (pure-JS entity registry) so
+ gamemodes are testable without the engine.
+- `ents.Create/FindByClass(wildcards)/FindInSphere/GetAll/Iterator/
+ GetByIndex/GetCount`, client `ents.CreateClientside`.
+- `scripted_ents.Register(ENT, class)` with `Base` prototype chains and
+ hot-repatch of live instances on re-register; lifecycle:
+ `SetupDataTables -> KeyValue -> Initialize -> Think(NextThink) -> Use/
+ Touch/OnTakeDamage -> OnRemove`.
+- Networked state: `SetNW*/GetNW*` keyed store + `NetworkVar` DTVar-lite
+ slots declared in `SetupDataTables`; server changes replicate to clients
+ via internal `OV_NWSync` net messages; `SetNWVarProxy`/`NetworkVarNotify`.
+- `Player` extends `Entity` (Nick/SteamID/UserID/Team/Kill/Give/Freeze/
+ ChatPrint/...); `player.GetAll/GetByUserID/Iterator`; `LocalPlayer()` on
+ the client.
+
+## File networking (js/core/file.js) — AddCSLuaFile equivalents
+
+- `AddCSJSFile(path?)` (alias `AddCSLuaFile` for muscle memory) — server-only
+ effect; default = currently loading file. Auto-networked: `autorun/`,
+ `autorun/client/`, gamemode `shared.js`/`client.js`, `entities/`.
+- Server builds a manifest {path, sha256, size}; on `ClientFullyConnected`
+ it net-sends the manifest; the client runtime diffs against its cache
+ (`js/ov_downloads/`), requests missing files via `OV_FileRequest`, and
+ loads client scripts from the synced set. `include(path)` executes a file
+ in the caller realm with caller-dir relative resolution.
+
+## Console commands (C++ + js/core/concommand.js)
+
+- `concommand.Add(name, fn(ply, cmd, args, argStr))` per realm.
+- Server: `js_run <code>`, `js_openscript <path>` (path relative to `js/`).
+- Client: `js_run_cl <code>`, `js_openscript_cl <path>` — gated by replicated
+ `sv_allowcsjs` (GMod's sv_allowcslua), never executable via the
+ server->client command channel.
+- Existing `ov_js_*` commands remain. `ov_npm <install|update> <pkg>` routes
+ to the Node runtime which shells out to npm in `js/` then hot-reloads.
+
+## Hotloading
+
+- Node backend: `fs.watch` over `js/` + `addons/` (exists) -> targeted
+ `require.reload` + `OnReloaded` hook; scripted entity re-register
+ hot-patches live instances.
+- Embedded backend: `ov_js_reload` full reload; `js_openscript` for ad-hoc.
+- npm: `ov_npm install <pkg>` (console/GUI) -> npm child process ->
+ watch-triggered reload. Works in the Node backend; embedded backend picks
+ packages up via `core/module.js` on next reload.
+
+## Runtime control server (engine/openvibe-js-runtime/ov-runtime.js)
+
+HTTP on 127.0.0.1:41997 (`server` realm; 41996 for `client` realm):
+- `GET /logs` — SSE stream of runtime + game log lines
+- `POST /eval` — `js_run` equivalent (body = code), returns result JSON
+- `POST /exec` — run a registered concommand / forward engine command
+- `POST /npm` — npm install/update into `js/`
+- `GET /state` — realm, mode, players, gamemode, hooks, entities
+This is what the GUI console talks to from both hosts.
+
+## Unified GUI (client/)
+
+One SPA (HTML/JS/CSS, no framework) served identically to Electron
+(`serve-client-ui.mjs` :5173), the backend (`/client/` :3000) and the
+in-game CEF panel (`ov_menu_url`). Routes: `portal, servers, leaderboard,
+inventory, shop, settings, options, console, hud`.
+
+- Console route: SSE log view + command input with history; JS eval
+ (server/client realm select) via runtime control server; engine commands
+ via `openvibe://cmd?c=` (in-game, allowlisted in C++) or runtime `/exec`.
+- Options route: game/video/audio convars (allowlisted set via
+ `openvibe://convar?name=&value=`), UI prefs (localStorage), account
+ settings (backend). Gamemode `manifest.json settings[]` render here too.
+- HUD route (`?hud=1` overlay): round state, teams, timers, health — driven
+ by `OV_HudState` net messages -> client runtime -> `ov_menu_js` push.
+- Launcher and in-game share route state via localStorage +
+ BroadcastChannel (existing openvibe-ui-sync contract, kept).
+
+## C++ additions (sdk/openvibe/)
+
+1. `shared/ov_js_entity.cpp` — entity natives + JS class (create, spawn,
+ remove, class/model/pos/angles/health/color/material/keyvalue/fire,
+ find queries, physics init/wake). Registered next to the player class.
+2. `openvibe_js_server.cpp` — `js_run`, `js_openscript`, `ov_npm`
+ ConCommands; Node-mode event forwarding for PlayerSpawn/Death/
+ InitialSpawn/Disconnected/Round* (gap fix).
+3. `openvibe_js_client.cpp` — client event forwarding (LevelInit, Think
+ ticks) to client runtime; `js_run_cl`, `js_openscript_cl` gated by
+ replicated `sv_allowcsjs`.
+4. `vgui_openvibe_menu.cpp` — bridge actions `cmd`, `convar`, `net`;
+ route commands `ov_menu_console`, `ov_menu_options`; console spew tap ->
+ `window.OV.onConsoleLine`.
+5. `apply-openvibe-sdk.sh` — new files + VPC entries + usermessage
+ `OVNetChunk`.
+
+## Testing
+
+Node harnesses (zero C++): `tools/smoke-js-core-node.mjs`,
+`tools/smoke-js-gamemodes.mjs` (all modes), plus new
+`tools/test-gmodjs.mjs` covering hooks (object ids, short-circuit), net
+(loopback pair of realms, rate limits, chunking), entities (SENT lifecycle,
+NW sync), file sync manifest, include/AddCSJSFile, concommand, hotload,
+gamemode loader. GUI: `tools/smoke-ui.mjs` (serves + asserts routes).
+SRCDS smoke unchanged.
@@ -1,214 +1,183 @@
@@ -1,214 +1,183 @@
-# OpenVibe JavaScript Scripting
+# OpenVibe JavaScript Scripting (GModJS)
-OpenVibe embeds QuickJS in the Source server DLL and exposes a small
-GMod-style hook/gamemode API.
+OpenVibe embeds a GMod-shaped JavaScript platform in Source. The same
+`game/openvibe.games/js/` framework runs under two backends:
-## File Layout
+- **embedded** — QuickJS compiled into the server DLL (`ov_js_backend embedded`)
+- **node** — `engine/openvibe-js-runtime/ov-runtime.js` over TCP
+ (`ov_js_backend node`; server realm 41999, client realm 41998) with full
+ npm (including native modules), fs.watch hot-reload, and the runtime
+ control server for the GUI console.
-```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:
+See `docs/GMODJS_ARCHITECTURE.md` for the design blueprint.
-```text
-js/gamemodes/<ov_mode>/server.js
-```
+## Realms
-## Hook API
+GMod States parity: `SERVER` / `CLIENT` globals (`js/core/realm.js`). The
+server realm is the embedded QuickJS or the server Node runtime; the client
+realm is the client Node runtime; the menu realm is the HTML GUI.
-Use the GMod-shaped API:
+## File layout & loading order
-```js
-hook.Add("PlayerSpawn", "MyAddon.SpawnMessage", function (ply) {
- ply.chat("Spawn hook from JS.");
-});
+```text
+js/
+ core/ hook, gamemode, util, net, entity, ents, player, team,
+ file, concommand, command, timer, module, addon, loader
+ autorun/ both realms (auto-networked)
+ autorun/server/ server only
+ autorun/client/ client only (auto-networked)
+ entities/ scripted entities (single-file or folder form)
+ gamemodes/<mode>/{manifest.json, shared.js, server.js, client.js}
+ node_modules/ npm packages (require()-able)
+ vendor/ local packages referenced from package.json (file:)
+addons/<name>/{addon.json, shared.js, server.js, client.js}
+```
-hook.Remove("PlayerSpawn", "MyAddon.SpawnMessage");
+Per realm (GMod Lua_Loading_Order): core → autorun (alphabetical; shared then
+realm dir) → gamemode chain (base first via `manifest.json "base"`) →
+scripted entities → addons → `Initialize`.
-const result = hook.Run("SomeHook", arg1, arg2);
-```
+## Hook library
-Lowercase aliases also exist:
+GMod semantics (`js/core/hook.js`):
```js
```js
-hook.add("PlayerSpawn", "id", fn);
-hook.remove("PlayerSpawn", "id");
-hook.run("PlayerSpawn", ply);
+hook.Add("PlayerSpawn", "MyAddon.Spawn", function (ply) { ... });
+hook.Add("Think", someEntity, function (self) { ... }); // object id: auto-removed when invalid, self prepended
+hook.Remove("PlayerSpawn", "MyAddon.Spawn");
+const result = hook.Run("SomeHook", a, b); // first non-undefined return wins (false counts)
```
```
-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.
+Registered hooks run before the `GAMEMODE` method and can override it.
-## Gamemode API
-
-`base/server.js` defines the base `GM`. Active modes inherit from it:
+## Gamemodes
```js
```js
-(function () {
- const GM = {
- mode: "deathrun",
- name: "OpenVibe Deathrun",
-
- Initialize() {
- OV.log("Deathrun initialized.");
- },
-
- PlayerInitialSpawn(ply) {
- ply.chat("Welcome to Deathrun.");
- }
- };
-
- gamemode.set(GM);
-})();
+const GM = { mode: "mymode", name: "My Mode",
+ CreateTeams() { team.SetUp(2, "Red", Color(235,75,60)); },
+ Initialize() {}, PlayerSpawn(ply) { hook.Run("PlayerLoadout", ply); } };
+gamemode.set(GM); // registers + derives from base + publishes GAMEMODE
```
```
-Globals:
+`gamemode.Register`, `DeriveGamemode`, `baseclass.Get("gamemode_<name>")`
+exist for GMod parity. `manifest.json` is the `<name>.txt` equivalent
+(`base`, `settings[]` rendered in the Options GUI).
-```js
-GM
-GAMEMODE
-gamemode.get()
-gamemode.getBase()
-gamemode.call("HookName", ...args)
-```
-
-## Events Exposed Today
+The base gamemode provides the round state machine
+(`idle → countdown → active → ended`), `RoundStart`/`RoundEnd` hooks, the
+`team` library, and HUD state replication (`OV_HudState` net message every
+3s + on transitions → client realm → HTML HUD overlay).
-The native server bridge currently fires:
+## net library
-```text
-Initialize
-MapInitialize(mapName)
-Think
-PlayerInitialSpawn(ply)
-PlayerSpawn(ply)
-PlayerDeath(victim, attacker)
-PlayerDisconnected(ply)
-PlayerSay(ply, text)
-ConsoleCommand(text)
-Shutdown
-```
+Full GMod surface (`js/core/net.js`): `net.Start(name, unreliable)` (name must
+be pooled server-side with `util.AddNetworkString` first), writers/readers for
+`Bit/Bool/Int/UInt/UInt64/Float/Double/String/Data/Vector/Normal/Angle/Color/
+Entity/Player/Table/Type`, `Send/Broadcast/SendOmit/SendToServer/Abort`,
+`BytesWritten/BytesLeft`, `net.Receive(name, fn(len, ply))` (one handler per
+name, case-insensitive; `ply` only on the server — the authoritative sender).
-## Commands
-
-Chat commands are routed through the `PlayerSay` hook.
+Faithful limits: 64KB payload cap, read-past-end returns type defaults, and
+per-player rate limiting: `net.SetRateLimit("MyMsg", 10)` (default 30/s).
+Transport chunks payloads over the 255-byte `OVNet` usermessage automatically.
+`SendPVS/SendPAS` currently broadcast (no native PVS data yet).
```js
```js
-command.Add("where", "Show mode and map", function ({ ply, reply }) {
- reply(ply, `Mode=${OV.getMode()} map=${OV.getMapName()}`);
- return false;
+// server
+util.AddNetworkString("MyMsg");
+net.Receive("MyMsg", function (len, ply) {
+ const value = net.ReadUInt(8); // validate: never trust the client
+ if (value > 100) return;
});
});
+// client
+net.Start("MyMsg"); net.WriteUInt(42, 8); net.SendToServer();
```
```
-Players type:
+## Entities
-```text
-!where
-```
+`js/core/entity.js` + `ents.js`: `Entity` wrapper with `IsValid/EntIndex/
+GetPos/SetPos/GetAngles/SetModel/Health/SetHealth/TakeDamage/PhysicsInit/
+GetPhysicsObject/SetParent/SetOwner/SetColor/SetKeyValue/Fire/Remove/
+CallOnRemove/...`, the `NULL` singleton, `SetNW*/GetNW*` networked vars, and
+DTVar-lite `NetworkVar` declared in `SetupDataTables` — server writes
+replicate to client shells automatically (`__ovnw`/`__ovdt`).
-Returning `false` blocks normal chat, matching the current C++ bridge behavior.
+`ents.Create/FindByClass("wildcard*")/FindInSphere/GetAll/Iterator/
+GetByIndex`, client `ents.CreateClientside`. With the native entity bridge
+compiled in, `ents.Create` drives real engine entities (`OV.entCreate` /
+`OV.entCall`); otherwise entities are logical (harness/tests/round logic).
-Console commands can be sent from SRCDS:
+Scripted entities (GMod SENT layout, `js/entities/`):
```text
```text
-ov_js_cmd where
+entities/ov_bouncy_crate/shared.js ENT.Type/Base/PrintName/SetupDataTables
+entities/ov_bouncy_crate/init.js server: AddCSJSFile + include(shared) + Initialize/Think/Use/OnTakeDamage/OnRemove
+entities/ov_bouncy_crate/cl_init.js client: include(shared)
```
```
-## Timers
+`scripted_ents.Register(ENT, class)` hot-patches live instances on
+re-register (`OnReloaded` fires). `Think` honors `self.NextThink(CurTime()+t)`.
-Timers are advanced from the `Think` hook:
+## Players
-```js
-timer.simple(3, function () {
- OV.broadcast("Three seconds passed.");
-});
+`Player` extends `Entity`: `Nick/SteamID/UserID/Team/SetTeam/Alive/Kill/
+Freeze/ChatPrint/ConCommand/Frags/...` plus the legacy lowercase API
+(`ply.chat`, `ply.setTeam`, ...). `player.GetAll/GetByUserID/GetBySteamID/
+Iterator`; `LocalPlayer()` on the client. Native handles from either backend
+are wrapped automatically in hook arguments.
-timer.create("round_tick", 1, 0, function (count) {
- OV.log(`Round tick ${count}`);
-});
-```
+## AddCSJSFile / include / client script sync
-`reps=0` means repeat forever.
+`AddCSJSFile(path?)` (alias `AddCSLuaFile`) marks a `js/`-relative file for
+client download; `autorun/`, `autorun/client/`, gamemode `shared.js`/
+`client.js` + `manifest.json`, and `entities/` are auto-networked.
+`include(path)` executes a file in the caller's realm (js/-rooted with
+caller-dir fallback). On join the server net-sends a `{path, hash, size}`
+manifest; the client requests missing files (rate-limited, allowlisted to the
+manifest) and caches them under `js/ov_downloads/`, then fires
+`OVFilesSynced`.
-## Native Bridge
+## Console commands & script execution
-Available server APIs:
+`concommand.Add(name, fn(ply, cmd, args, argStr))` per realm;
+`RunConsoleCommand(name, ...args)` with a blocklist (can never trigger
+`js_run_cl` etc.). Chat commands stay on `command.Add` (`!cmd`).
-```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)
-```
+Engine console (GMod `lua_*` equivalents):
-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")
+```text
+js_run <code> server realm eval
+js_openscript <path> run js/<path> on the server (re-runs on repeat)
+js_run_cl <code> client realm eval (gated by sv_allowcsjs)
+js_openscript_cl <path> run js/<path> on the client (gated by sv_allowcsjs)
+ov_npm install <pkg> npm into js/ + hot-reload (node backend)
+ov_js_reload / ov_js_status / ov_js_fire / ov_js_cmd (existing)
```
```
-Server and player command execution is allowlisted to `ov_*` commands.
-
-## Reloading
+## Hotloading
-In SRCDS:
+- node backend: `fs.watch` on `js/` + `addons/` → debounced full reload →
+ `Initialize` + `OnReloaded`; scripted entity re-register hot-patches live
+ instances; `require.reload(id)` for targeted reloads.
+- embedded backend: `ov_js_reload` (full), `js_openscript` (ad-hoc).
+- npm: `ov_npm install <pkg>` (console), `POST /npm` (GUI console), or edit
+ `js/package.json` — the watcher reloads on node_modules changes. Local
+ packages live in `js/vendor/` via `file:` deps.
-```text
-ov_js_status
-ov_js_reload
-ov_js_fire Initialize
-ov_js_cmd smoke
-```
+## Runtime control server (GUI console backend)
-## Next Native Hooks To Add
-
-Good next bridge events:
+Each Node runtime serves HTTP on 127.0.0.1 (server realm 41997, client 41996):
```text
```text
-EntityTakeDamage(entity, damageInfo)
-PlayerUse(ply, entity)
-PlayerCanPickupWeapon(ply, weapon)
-PlayerLoadout(ply)
+GET /health GET /state GET /logs (SSE)
+POST /eval {code} POST /exec {command} POST /npm {args} POST /openscript {path}
```
```
-Already added via `ov_round_start` / `ov_round_end` ConCommands and direct C++ bridge calls:
+## Testing (no C++ build needed)
-```text
-RoundStart(roundNumber)
-RoundEnd(roundNumber, reason)
+```bash
+node tools/test-gmodjs.mjs # 131-check framework suite (paired realms)
+node tools/smoke-js-core-node.mjs # legacy core smoke
+node tools/smoke-js-gamemodes.mjs # all five modes lifecycle
+node tools/smoke-runtime-ipc.mjs # real TCP IPC flow across both runtimes
```
```
@@ -30,13 +30,26 @@ function arg(name, def) {
@@ -30,13 +30,26 @@ function arg(name, def) {
const REALM = arg('realm', 'server'); // "server" | "client"
const REALM = arg('realm', 'server'); // "server" | "client"
const MODE = arg('mode', 'sandbox');
const MODE = arg('mode', 'sandbox');
const PORT = Number(arg('port', REALM === 'client' ? 41998 : 41999));
const PORT = Number(arg('port', REALM === 'client' ? 41998 : 41999));
+const CTRL_PORT = Number(arg('ctrl-port', REALM === 'client' ? 41996 : 41997));
const ROOT = path.resolve(arg('root', path.join(__dirname, '..', '..')));
const ROOT = path.resolve(arg('root', path.join(__dirname, '..', '..')));
const MOD = path.join(ROOT, 'game', 'openvibe.games');
const MOD = path.join(ROOT, 'game', 'openvibe.games');
const JS = path.join(MOD, 'js');
const JS = path.join(MOD, 'js');
const IS_SERVER = REALM === 'server';
const IS_SERVER = REALM === 'server';
-function log(...a) { console.log(`[ov-runtime/${REALM}]`, ...a); }
-function warn(...a) { console.warn(`[ov-runtime/${REALM}] WARN`, ...a); }
+// ---- log ring buffer + SSE fanout (consumed by the GUI console) ----
+const LOG_RING_MAX = 500;
+const logRing = [];
+const sseClients = new Set();
+let logSeq = 0;
+function pushLog(level, text) {
+ const entry = { seq: ++logSeq, t: Date.now(), level, text: String(text), realm: REALM };
+ logRing.push(entry);
+ if (logRing.length > LOG_RING_MAX) logRing.shift();
+ const data = `data: ${JSON.stringify(entry)}\n\n`;
+ for (const res of sseClients) { try { res.write(data); } catch { sseClients.delete(res); } }
+}
+function log(...a) { console.log(`[ov-runtime/${REALM}]`, ...a); pushLog('info', a.join(' ')); }
+function warn(...a) { console.warn(`[ov-runtime/${REALM}] WARN`, ...a); pushLog('warn', a.join(' ')); }
// ---- IPC: one connected game client at a time ----
// ---- IPC: one connected game client at a time ----
let sock = null;
let sock = null;
@@ -62,29 +75,47 @@ function buildOV() {
@@ -62,29 +75,47 @@ function buildOV() {
return {
return {
log: (m) => { log('[js]', m); },
log: (m) => { log('[js]', m); },
warn: (m) => { warn('[js]', m); },
warn: (m) => { warn('[js]', m); },
- error: (m) => { console.error(`[ov-runtime/${REALM}] [js ERROR]`, m); },
+ error: (m) => { console.error(`[ov-runtime/${REALM}] [js ERROR]`, m); pushLog('error', String(m)); },
isServer: () => IS_SERVER,
isServer: () => IS_SERVER,
getMode: () => MODE,
getMode: () => MODE,
getMapName: () => state.map,
getMapName: () => state.map,
time: () => Date.now() / 1000,
time: () => Date.now() / 1000,
readFile: (p) => { try { return fs.readFileSync(modRel(p), 'utf8'); } catch { return null; } },
readFile: (p) => { try { return fs.readFileSync(modRel(p), 'utf8'); } catch { return null; } },
- fileExists: (p) => { try { return fs.statSync(modRel(p)).isFile(); } catch { return false; } },
+ fileExists: (p) => { try { fs.statSync(modRel(p)); return true; } catch { return false; } },
listDir: (d /*, wildcard */) => { try { return fs.readdirSync(modRel(d)); } catch { return []; } },
listDir: (d /*, wildcard */) => { try { return fs.readdirSync(modRel(d)); } catch { return []; } },
+ // Write access is jailed to the client download cache + data dir.
+ writeFile: (p, content) => {
+ const norm = path.normalize(String(p)).replace(/^([/\\]|\.\.)+/, '');
+ if (!norm.startsWith(path.join('js', 'ov_downloads')) && !norm.startsWith('data')) {
+ warn('writeFile blocked outside js/ov_downloads|data:', norm);
+ return false;
+ }
+ try {
+ const full = modRel(norm);
+ fs.mkdirSync(path.dirname(full), { recursive: true });
+ fs.writeFileSync(full, String(content));
+ return true;
+ } catch (e) { warn('writeFile', e.message); return false; }
+ },
freeMem: () => {},
freeMem: () => {},
players: () => Object.values(state.players).map(wrapPlayer),
players: () => Object.values(state.players).map(wrapPlayer),
playerByUserId: (id) => state.players[id] ? wrapPlayer(state.players[id]) : null,
playerByUserId: (id) => state.players[id] ? wrapPlayer(state.players[id]) : null,
+ localPlayer: () => (!IS_SERVER && state.localUserId != null && state.players[state.localUserId])
+ ? wrapPlayer(state.players[state.localUserId]) : null,
broadcast: (msg) => sendToGame({ t: 'chat', msg: String(msg) }),
broadcast: (msg) => sendToGame({ t: 'chat', msg: String(msg) }),
serverCommand: (cmd) => sendToGame({ t: 'concmd', cmd: String(cmd) }),
serverCommand: (cmd) => sendToGame({ t: 'concmd', cmd: String(cmd) }),
netEmit: (idsCsv, name, payloadB64) => sendToGame({ t: 'net', ids: String(idsCsv), name: String(name), payload: String(payloadB64) }),
netEmit: (idsCsv, name, payloadB64) => sendToGame({ t: 'net', ids: String(idsCsv), name: String(name), payload: String(payloadB64) }),
netSendToServer: (name, payloadB64) => sendToGame({ t: 'net', toServer: true, name: String(name), payload: String(payloadB64) }),
netSendToServer: (name, payloadB64) => sendToGame({ t: 'net', toServer: true, name: String(name), payload: String(payloadB64) }),
fireHook: (name, ...args) => { try { return ctx.hook.Run(name, ...args); } catch (e) { warn('fireHook', e.message); } },
fireHook: (name, ...args) => { try { return ctx.hook.Run(name, ...args); } catch (e) { warn('fireHook', e.message); } },
+ // Push JS into the in-game HTML panel (client realm): runs ov_menu_js.
+ menuJS: (script) => sendToGame({ t: 'concmd', cmd: 'ov_menu_js ' + String(script) }),
reward: () => {},
reward: () => {},
endMatch: () => {},
endMatch: () => {},
};
};
}
}
// Live game state mirrored from IPC events.
// Live game state mirrored from IPC events.
-const state = { map: '', players: {} /* userId -> {userId,name,steamId,...} */ };
+const state = { map: '', players: {} /* userId -> {userId,name,steamId,...} */, localUserId: null };
// Return a wrapped player, registering a minimal record if we haven't seen a
// Return a wrapped player, registering a minimal record if we haven't seen a
// full connect event yet (so chat replies / net handlers always have a target).
// full connect event yet (so chat replies / net handlers always have a target).
function ensurePlayer(userId, name) {
function ensurePlayer(userId, name) {
@@ -94,14 +125,22 @@ function ensurePlayer(userId, name) {
@@ -94,14 +125,22 @@ function ensurePlayer(userId, name) {
return wrapPlayer(state.players[userId]);
return wrapPlayer(state.players[userId]);
}
}
function wrapPlayer(p) {
function wrapPlayer(p) {
- return {
+ const native = {
userId: () => p.userId, entIndex: () => p.entIndex || p.userId,
userId: () => p.userId, entIndex: () => p.entIndex || p.userId,
steamId: () => p.steamId || '', name: () => p.name || ('Player' + p.userId),
steamId: () => p.steamId || '', name: () => p.name || ('Player' + p.userId),
- health: () => p.health || 100, setHealth: (v) => sendToGame({ t: 'player', userId: p.userId, setHealth: v }),
+ health: () => p.health || 100, setHealth: (v) => { p.health = v; sendToGame({ t: 'player', userId: p.userId, setHealth: v }); },
team: () => p.team || 0, setTeam: (v) => { p.team = v; sendToGame({ t: 'player', userId: p.userId, setTeam: v }); },
team: () => p.team || 0, setTeam: (v) => { p.team = v; sendToGame({ t: 'player', userId: p.userId, setTeam: v }); },
chat: (m) => sendToGame({ t: 'chat', userId: p.userId, msg: String(m) }),
chat: (m) => sendToGame({ t: 'chat', userId: p.userId, msg: String(m) }),
runCommand: (c) => sendToGame({ t: 'runcmd', userId: p.userId, cmd: String(c) }),
runCommand: (c) => sendToGame({ t: 'runcmd', userId: p.userId, cmd: String(c) }),
+ getPos: () => p.pos || null,
+ kill: () => sendToGame({ t: 'player', userId: p.userId, kill: true }),
};
};
+ // Upgrade to the framework Player class when loaded (gamemodes then get the
+ // full GMod surface: Nick/SetTeam/ChatPrint/NW vars/...).
+ if (ctx && ctx.Player && typeof ctx.Player.fromNative === 'function') {
+ try { return ctx.Player.fromNative(native); } catch { /* fall through */ }
+ }
+ return native;
}
}
// Build the Node require npm + the addon loader use. Resolved from the mod's js
// Build the Node require npm + the addon loader use. Resolved from the mod's js
@@ -130,18 +169,20 @@ function buildRequire() {
@@ -130,18 +169,20 @@ function buildRequire() {
function loadFramework() {
function loadFramework() {
// Clear framework globals so re-eval redefines them (core files guard against
// Clear framework globals so re-eval redefines them (core files guard against
// re-init, which would otherwise no-op a hot-reload).
// re-init, which would otherwise no-op a hot-reload).
- for (const g of ['hook', 'gamemode', 'GM', 'GAMEMODE', 'net', 'util', 'command', 'concommand', 'timer', 'Addon', 'module', 'OVSandbox']) {
+ for (const g of ['hook', 'gamemode', 'GM', 'GAMEMODE', 'net', 'util', 'command', 'concommand',
+ 'timer', 'Addon', 'module', 'OVSandbox', 'Entity', 'Player', 'player', 'ents',
+ 'scripted_ents', 'file', 'OVLoader', 'include', 'AddCSJSFile', 'AddCSLuaFile',
+ 'SERVER', 'CLIENT', 'MENU', 'NULL', 'LocalPlayer', 'RunConsoleCommand',
+ 'DeriveGamemode', 'baseclass', 'IsValid', 'CurTime', 'ENT']) {
try { delete global[g]; } catch {}
try { delete global[g]; } catch {}
}
}
global.OV = buildOV();
global.OV = buildOV();
global.require = buildRequire();
global.require = buildRequire();
- const order = [
- 'core/hook.js', 'core/gamemode.js', 'bridge.js', 'core/command.js', 'core/timer.js',
- 'gamemodes/base/server.js',
- `gamemodes/${MODE}/shared.js`,
- `gamemodes/${MODE}/${IS_SERVER ? 'server' : 'client'}.js`,
- ];
+ // Core files in the same order the embedded C++ loader uses; bridge.js
+ // bootstraps the remaining core libraries (realm/util/net/entity/ents/
+ // player/file/concommand/addon/loader).
+ const order = ['core/hook.js', 'core/gamemode.js', 'bridge.js', 'core/command.js', 'core/timer.js'];
for (const f of order) {
for (const f of order) {
const full = path.join(JS, f);
const full = path.join(JS, f);
if (!fs.existsSync(full)) continue;
if (!fs.existsSync(full)) continue;
@@ -149,6 +190,21 @@ function loadFramework() {
@@ -149,6 +190,21 @@ function loadFramework() {
catch (e) { warn(`load ${f}: ${e.message}`); }
catch (e) { warn(`load ${f}: ${e.message}`); }
}
}
ctx = global;
ctx = global;
+
+ // GMod loading order: autorun -> gamemode chain -> entities -> addons.
+ if (ctx.OVLoader && typeof ctx.OVLoader.loadAll === 'function') {
+ try { ctx.OVLoader.loadAll({ mode: MODE }); }
+ catch (e) { warn('OVLoader.loadAll:', e.message); }
+ } else {
+ // Fallback for a stripped js/ tree (old layout).
+ for (const f of ['gamemodes/base/server.js', `gamemodes/${MODE}/shared.js`,
+ `gamemodes/${MODE}/${IS_SERVER ? 'server' : 'client'}.js`]) {
+ const full = path.join(JS, f);
+ if (!fs.existsSync(full)) continue;
+ try { vm.runInThisContext(fs.readFileSync(full, 'utf8'), { filename: f }); }
+ catch (e) { warn(`load ${f}: ${e.message}`); }
+ }
+ }
log(`framework loaded (mode=${MODE}, realm=${REALM})`);
log(`framework loaded (mode=${MODE}, realm=${REALM})`);
}
}
@@ -195,11 +251,18 @@ function onGameMessage(msg) {
@@ -195,11 +251,18 @@ function onGameMessage(msg) {
break;
break;
}
}
case 'player_connect':
case 'player_connect':
- state.players[msg.userId] = { userId: msg.userId, name: msg.name, steamId: msg.steamId };
+ state.players[msg.userId] = { userId: msg.userId, name: msg.name, steamId: msg.steamId, entIndex: msg.entIndex };
+ if (msg.local) state.localUserId = msg.userId;
+ fire('PlayerConnect', msg.name, msg.ip || '');
break;
break;
case 'player_disconnect':
case 'player_disconnect':
+ fire('PlayerDisconnected', state.players[msg.userId] ? wrapPlayer(state.players[msg.userId]) : null);
delete state.players[msg.userId];
delete state.players[msg.userId];
break;
break;
+ case 'local_player':
+ state.localUserId = msg.userId;
+ if (!state.players[msg.userId]) state.players[msg.userId] = { userId: msg.userId, name: msg.name, steamId: msg.steamId, entIndex: msg.entIndex };
+ break;
case 'net': {
case 'net': {
// client->server (or server->client) net message -> OVNetReceive hook
// client->server (or server->client) net message -> OVNetReceive hook
const ply = ensurePlayer(msg.userId, msg.name_) ;
const ply = ensurePlayer(msg.userId, msg.name_) ;
@@ -215,11 +278,159 @@ function onGameMessage(msg) {
@@ -215,11 +278,159 @@ function onGameMessage(msg) {
case 'think':
case 'think':
if (ctx && ctx.hook) { try { ctx.hook.Run('Think'); } catch {} }
if (ctx && ctx.hook) { try { ctx.hook.Run('Think'); } catch {} }
break;
break;
+ case 'eval':
+ // js_run / js_run_cl from the game console.
+ if (ctx && ctx.OVLoader && typeof ctx.OVLoader.runString === 'function') {
+ try { ctx.OVLoader.runString(msg.code, 'console'); } catch (e) { warn('eval:', e.message); }
+ }
+ break;
+ case 'openscript':
+ // js_openscript / js_openscript_cl from the game console.
+ if (ctx && ctx.OVLoader && typeof ctx.OVLoader.openScript === 'function') {
+ try { ctx.OVLoader.openScript(msg.path); } catch (e) { warn('openscript:', e.message); }
+ }
+ break;
+ case 'npm':
+ // ov_npm from the game console (server realm).
+ runNpm(msg.args || [], () => {});
+ break;
default:
default:
break;
break;
}
}
}
}
+// ---- runtime control HTTP server (GUI console / options / tooling) ----
+// GET /logs SSE stream of runtime log lines (plus a small backlog)
+// GET /state realm/mode/map/players/gamemode/hooks summary
+// POST /eval { code } js_run equivalent in this realm
+// POST /exec { command } concommand dispatch or engine forward
+// POST /npm { args: ["install", "pkg"] } npm in the js/ tree + hot reload
+// GET /health
+const http = require('http');
+const { execFile: cpExecFile } = require('child_process');
+
+function readBody(req) {
+ return new Promise((resolve) => {
+ let buf = '';
+ req.on('data', (c) => { buf += c; if (buf.length > 1 << 20) req.destroy(); });
+ req.on('end', () => { try { resolve(buf ? JSON.parse(buf) : {}); } catch { resolve({}); } });
+ });
+}
+function json(res, code, obj) {
+ const body = JSON.stringify(obj);
+ res.writeHead(code, {
+ 'Content-Type': 'application/json',
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Content-Type',
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
+ });
+ res.end(body);
+}
+
+let npmBusy = false;
+function runNpm(args, cb) {
+ if (npmBusy) return cb({ ok: false, error: 'npm already running' });
+ const allowed = ['install', 'i', 'update', 'uninstall', 'remove', 'ls', 'list'];
+ if (!args.length || !allowed.includes(args[0])) return cb({ ok: false, error: `npm subcommand not allowed: ${args[0] || '(none)'}` });
+ const safe = args.filter((a) => /^[-@a-zA-Z0-9._/^~:]+$/.test(String(a)));
+ npmBusy = true;
+ log('npm', safe.join(' '));
+ cpExecFile('npm', [...safe, '--no-fund', '--no-audit'], { cwd: JS, timeout: 120000 }, (err, stdout, stderr) => {
+ npmBusy = false;
+ const out = String(stdout || '') + String(stderr || '');
+ out.split('\n').filter(Boolean).slice(0, 40).forEach((l) => pushLog('npm', l));
+ if (err) return cb({ ok: false, error: err.message, output: out });
+ // fs.watch on js/ picks up node_modules changes and hot-reloads; force one
+ // anyway so `npm ls`-style no-op writes still refresh requires.
+ loadFramework();
+ fire('Initialize');
+ fire('OnReloaded');
+ cb({ ok: true, output: out });
+ });
+}
+
+const ctrl = http.createServer(async (req, res) => {
+ const url = new URL(req.url, 'http://127.0.0.1');
+ if (req.method === 'OPTIONS') return json(res, 200, { ok: true });
+ if (req.method === 'GET' && url.pathname === '/health') {
+ return json(res, 200, { ok: true, service: 'ov-runtime', realm: REALM, mode: MODE });
+ }
+ if (req.method === 'GET' && url.pathname === '/logs') {
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ 'Access-Control-Allow-Origin': '*',
+ });
+ for (const entry of logRing.slice(-100)) res.write(`data: ${JSON.stringify(entry)}\n\n`);
+ sseClients.add(res);
+ req.on('close', () => sseClients.delete(res));
+ return;
+ }
+ if (req.method === 'GET' && url.pathname === '/state') {
+ let gm = null, hooks = 0, entities = 0;
+ try {
+ gm = ctx && ctx.GAMEMODE ? { mode: ctx.GAMEMODE.mode, name: ctx.GAMEMODE.name } : null;
+ if (ctx && ctx.hook) hooks = Object.keys(ctx.hook.GetTable()).length;
+ if (ctx && ctx.ents) entities = ctx.ents.GetCount();
+ } catch {}
+ return json(res, 200, {
+ ok: true, realm: REALM, mode: MODE, map: state.map, gameConnected: !!(sock && !sock.destroyed),
+ players: Object.values(state.players).map((p) => ({ userId: p.userId, name: p.name, team: p.team || 0 })),
+ gamemode: gm, hookEvents: hooks, entities,
+ });
+ }
+ if (req.method === 'POST' && url.pathname === '/eval') {
+ const body = await readBody(req);
+ if (typeof body.code !== 'string') return json(res, 400, { ok: false, error: 'missing code' });
+ let out;
+ if (ctx && ctx.OVLoader && ctx.OVLoader.runString) out = ctx.OVLoader.runString(body.code, 'console-eval');
+ else { try { out = { ok: true, result: vm.runInThisContext(body.code, { filename: 'console-eval' }) }; } catch (e) { out = { ok: false, error: e.message }; } }
+ let rendered = null;
+ try { rendered = out.result === undefined ? null : JSON.parse(JSON.stringify(out.result)); }
+ catch { rendered = String(out.result); }
+ pushLog('eval', `> ${body.code}`);
+ if (out.ok) pushLog('eval', `= ${rendered === null ? 'undefined' : JSON.stringify(rendered)}`);
+ else pushLog('error', out.error);
+ return json(res, 200, { ok: out.ok, result: rendered, error: out.error || null });
+ }
+ if (req.method === 'POST' && url.pathname === '/exec') {
+ const body = await readBody(req);
+ const line = String(body.command || '').trim();
+ if (!line) return json(res, 400, { ok: false, error: 'missing command' });
+ pushLog('cmd', `] ${line}`);
+ let handled = false;
+ try {
+ if (ctx && ctx.concommand && ctx.concommand.Dispatch) handled = !!ctx.concommand.Dispatch(null, line);
+ if (!handled && ctx && ctx.command && ctx.command.dispatchConsole) {
+ const r = ctx.command.dispatchConsole(line);
+ handled = r !== undefined;
+ }
+ } catch (e) { return json(res, 200, { ok: false, error: e.message }); }
+ if (!handled) {
+ // Forward to the engine (the DLL applies its ov_*/say allowlist).
+ sendToGame({ t: 'concmd', cmd: line });
+ handled = 'forwarded';
+ }
+ return json(res, 200, { ok: true, handled });
+ }
+ if (req.method === 'POST' && url.pathname === '/npm') {
+ const body = await readBody(req);
+ const args = Array.isArray(body.args) ? body.args.map(String) : [];
+ return runNpm(args, (result) => json(res, result.ok ? 200 : 400, result));
+ }
+ if (req.method === 'POST' && url.pathname === '/openscript') {
+ const body = await readBody(req);
+ const p = String(body.path || '');
+ if (!p) return json(res, 400, { ok: false, error: 'missing path' });
+ const ok = !!(ctx && ctx.OVLoader && ctx.OVLoader.openScript(p));
+ return json(res, 200, { ok });
+ }
+ return json(res, 404, { ok: false, error: 'not found' });
+});
+ctrl.on('error', (e) => warn('control server error:', e.message));
+ctrl.listen(CTRL_PORT, '127.0.0.1', () => log(`control server on 127.0.0.1:${CTRL_PORT}`));
+
// ---- TCP server ----
// ---- TCP server ----
const server = net.createServer((s) => {
const server = net.createServer((s) => {
if (sock && !sock.destroyed) { warn('replacing existing game connection'); try { sock.destroy(); } catch {} }
if (sock && !sock.destroyed) { warn('replacing existing game connection'); try { sock.destroy(); } catch {} }
@@ -1,2 +1,3 @@
@@ -1,2 +1,3 @@
// OpenVibe autoexec. Safe in both native and Proton modes.
// OpenVibe autoexec. Safe in both native and Proton modes.
exec openvibe_proton_client.cfg
exec openvibe_proton_client.cfg
+exec openvibe_client_default.cfg
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+// OpenVibe client defaults — the HTML GUI console replaces the stock Source
+// console. Executed from autoexec.cfg.
+//
+// con_enable 0 keeps the engine's VGUI console from opening; the console key
+// toggles the OpenVibe console route instead (ov_console, from client.dll).
+// Use "con_enable 1; toggleconsole" from the OpenVibe console if the stock
+// console is ever needed for emergencies.
+con_enable 0
+bind "`" "ov_console"
+bind "F10" "ov_console"
+bind "F1" "ov_menu_main"
+echo "OpenVibe: stock console disabled; ` and F10 toggle the OpenVibe console"
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+// Example autorun script — runs in BOTH realms before the gamemode loads
+// (GMod lua/autorun equivalent). Auto-networked to clients.
+(function () {
+ if (SERVER) {
+ hook.Add("PlayerInitialSpawn", "OpenVibeMotd", function (ply) {
+ if (globalThis.timer && timer.Simple) {
+ timer.Simple(1, function () {
+ if (ply && ply.ChatPrint) ply.ChatPrint("[MOTD] OpenVibe GModJS platform — !help for commands, F10 for console.");
+ });
+ }
+ });
+ } else {
+ hook.Add("Initialize", "OpenVibeMotdClient", function () {
+ OV.log("[MOTD] autorun ran on the client realm");
+ });
+ }
+})();
@@ -27,9 +27,18 @@
@@ -27,9 +27,18 @@
}
}
}
}
- bootstrap("js/core/module.js"); // defines globalThis.require
- bootstrap("js/core/net.js"); // defines globalThis.net + util.AddNetworkString
- bootstrap("js/core/addon.js"); // defines globalThis.Addon
+ bootstrap("js/core/realm.js"); // SERVER / CLIENT / IsValid / CurTime
+ bootstrap("js/core/module.js"); // globalThis.require (CommonJS + npm)
+ bootstrap("js/core/util.js"); // util.AddNetworkString pooling + helpers
+ bootstrap("js/core/net.js"); // GMod net library
+ bootstrap("js/core/entity.js"); // Entity class + NW/DT vars
+ bootstrap("js/core/ents.js"); // ents + scripted_ents
+ bootstrap("js/core/player.js"); // Player class + player library
+ bootstrap("js/core/team.js"); // team library
+ bootstrap("js/core/file.js"); // AddCSJSFile / include / client sync
+ bootstrap("js/core/concommand.js"); // concommand + RunConsoleCommand
+ bootstrap("js/core/addon.js"); // globalThis.Addon
+ bootstrap("js/core/loader.js"); // OVLoader (autorun/gamemodes/entities)
// NOTE: Addon.loadAll() is intentionally NOT called here. bridge.js loads
// NOTE: Addon.loadAll() is intentionally NOT called here. bridge.js loads
// before command.js/timer.js in the C++ core order, so addons that register
// before command.js/timer.js in the C++ core order, so addons that register
@@ -55,8 +55,13 @@
@@ -55,8 +55,13 @@
const entry = commands[parsed.name];
const entry = commands[parsed.name];
if (!entry) {
if (!entry) {
- reply(ply, `Unknown command: ${parsed.name}. Try !help`);
- return false;
+ // Chat (!cmd) gets feedback; console lines fall through to other
+ // handlers (concommand registry, engine) instead.
+ if (ply) {
+ reply(ply, `Unknown command: ${parsed.name}. Try !help`);
+ return false;
+ }
+ return undefined;
}
}
const result = entry.fn({ ply, name: parsed.name, args: parsed.args, raw: parsed.raw, reply });
const result = entry.fn({ ply, name: parsed.name, args: parsed.args, raw: parsed.raw, reply });
@@ -84,25 +89,21 @@
@@ -84,25 +89,21 @@
}
}
};
};
- 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();
- }
- };
+ // NOTE: the GMod-style concommand library lives in core/concommand.js
+ // (loaded by bridge.js before this file); command.js only owns chat (!cmd)
+ // commands. A minimal shim is kept for hosts without the new core files.
+ if (!globalThis.concommand) {
+ globalThis.concommand = {
+ Add(name, fn, help) {
+ return command.add(name, help || "", function ({ ply, args, raw, reply }) {
+ return fn(ply || null, name, args, raw);
+ });
+ },
+ Remove(name) {
+ return command.remove(name);
+ }
+ };
+ }
hook.add("PlayerSay", "OpenVibeCommandRegistry", function (ply, text) {
hook.add("PlayerSay", "OpenVibeCommandRegistry", function (ply, text) {
const msg = String(text || "").trim();
const msg = String(text || "").trim();
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+// OpenVibe concommand library — GMod concommand semantics.
+// https://wiki.facepunch.com/gmod/concommand
+//
+// Realm-local registry. Server commands are dispatched from the
+// "ConsoleCommand" hook (fed by the ov_js_cmd ConCommand and the runtime
+// control server); client commands from the client runtime's /exec channel.
+// RunConsoleCommand forwards to the engine through the bridge, with a
+// blocklist so scripts can never trigger the client-realm eval commands.
+(function () {
+ if (globalThis.concommand && globalThis.concommand.__openvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+
+ var commands = Object.create(null); // lowercase name -> {fn, help}
+
+ // Commands Lua/JS may never trigger programmatically (GMod Blocked_ConCommands
+ // spirit): client-realm eval + engine-critical toggles.
+ var BLOCKED = { js_run_cl: 1, js_openscript_cl: 1, ov_js_run_cl: 1, exec: 1, connect: 1, retry: 1, bind: 1, unbindall: 1, alias: 1, sv_cheats: 1 };
+
+ function parseArgs(str) {
+ var out = [];
+ var rx = /"([^"]*)"|(\S+)/g, m;
+ while ((m = rx.exec(str))) out.push(m[1] !== undefined ? m[1] : m[2]);
+ return out;
+ }
+
+ var concommand = {
+ __openvibe: true,
+ Add: function (name, fn, autoComplete, helpText) {
+ if (typeof fn !== "function") throw new Error("concommand.Add requires a callback");
+ commands[String(name).toLowerCase()] = { fn: fn, help: helpText || "" };
+ },
+ Remove: function (name) { delete commands[String(name).toLowerCase()]; },
+ GetTable: function () {
+ var out = {};
+ for (var k in commands) out[k] = commands[k].fn;
+ return out;
+ },
+ Run: function (ply, cmd, args, argStr) {
+ var entry = commands[String(cmd).toLowerCase()];
+ if (!entry) return false;
+ try { entry.fn(ply || null, String(cmd), args || [], argStr || ""); }
+ catch (e) { OV && OV.error && OV.error("concommand '" + cmd + "': " + (e && e.stack ? e.stack : e)); }
+ return true;
+ },
+ // Dispatch a raw console line ("name arg1 arg2 ...").
+ Dispatch: function (ply, line) {
+ line = String(line || "").trim();
+ if (!line) return false;
+ var space = line.indexOf(" ");
+ var name = space < 0 ? line : line.slice(0, space);
+ var argStr = space < 0 ? "" : line.slice(space + 1);
+ return concommand.Run(ply, name, parseArgs(argStr), argStr);
+ }
+ };
+
+ globalThis.concommand = concommand;
+
+ globalThis.RunConsoleCommand = function (name) {
+ name = String(name).toLowerCase();
+ if (BLOCKED[name]) {
+ OV && OV.warn && OV.warn("RunConsoleCommand: '" + name + "' is blocked");
+ return;
+ }
+ var args = Array.prototype.slice.call(arguments, 1).map(String);
+ var line = name + (args.length ? " " + args.map(function (a) { return /\s/.test(a) ? '"' + a + '"' : a; }).join(" ") : "");
+ if (concommand.Dispatch(null, line)) return; // JS-registered command
+ if (OV && OV.serverCommand) OV.serverCommand(line); // engine (ov_*/say allowlist applies)
+ };
+
+ // Server realm: wire into the existing ConsoleCommand hook (ov_js_cmd text).
+ if (globalThis.hook && typeof hook.Add === "function") {
+ hook.Add("ConsoleCommand", "OpenVibeConCommandDispatch", function (text) {
+ if (concommand.Dispatch(null, text)) return false; // handled
+ return undefined;
+ });
+ }
+
+ if (OV && OV.log) OV.log("concommand library ready");
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,374 @@
@@ -0,0 +1,374 @@
+// OpenVibe Entity system — GMod Entity class semantics in JS.
+// https://wiki.facepunch.com/gmod/Entity
+//
+// Entities are id-handled wrappers over an internal registry. When the native
+// bridge exposes entity bindings (OV.entCreate/OV.entCall) the wrapper drives
+// the real engine entity; otherwise it is a pure-JS logical entity with the
+// same lifecycle (usable in the Node harness, tests, and round logic).
+//
+// Networked state:
+// - SetNW*/GetNW* — keyed store, server writes replicate via "__ovnw"
+// - NetworkVar — DTVar-lite slots declared in ENT:SetupDataTables,
+// replicate via "__ovdt", generate Get<Name>/Set<Name>
+(function () {
+ if (globalThis.Entity && globalThis.Entity.__openvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+
+ // ---- math globals (GMod parity) ----
+ if (!globalThis.Vector) {
+ globalThis.Vector = function (x, y, z) { return { x: +x || 0, y: +y || 0, z: +z || 0 }; };
+ }
+ if (!globalThis.Angle) {
+ globalThis.Angle = function (p, y, r) { return { p: +p || 0, y: +y || 0, r: +r || 0 }; };
+ }
+ if (!globalThis.Color) {
+ globalThis.Color = function (r, g, b, a) { return { r: r | 0, g: g | 0, b: b | 0, a: a === undefined ? 255 : a | 0 }; };
+ }
+ function vcopy(v) { return { x: +v.x || 0, y: +v.y || 0, z: +v.z || 0 }; }
+ function acopy(a) { return { p: +a.p || 0, y: +a.y || 0, r: +a.r || 0 }; }
+ function dist2(a, b) {
+ var dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
+ return dx * dx + dy * dy + dz * dz;
+ }
+
+ // ---- enums (subset used by gamemodes) ----
+ globalThis.SOLID_NONE = 0; globalThis.SOLID_BBOX = 2; globalThis.SOLID_VPHYSICS = 6;
+ globalThis.MOVETYPE_NONE = 0; globalThis.MOVETYPE_WALK = 2; globalThis.MOVETYPE_NOCLIP = 8;
+ globalThis.MOVETYPE_VPHYSICS = 6; globalThis.MOVETYPE_FLY = 5;
+ globalThis.COLLISION_GROUP_NONE = 0; globalThis.COLLISION_GROUP_DEBRIS = 1; globalThis.COLLISION_GROUP_WORLD = 3;
+ globalThis.RENDERMODE_NORMAL = 0; globalThis.RENDERMODE_TRANSALPHA = 4;
+ globalThis.SIMPLE_USE = 0; globalThis.CONTINUOUS_USE = 1; globalThis.ONOFF_USE = 2;
+
+ // ---- registry ----
+ var registry = Object.create(null); // key(entIndex) -> Entity
+ var pendingRemoval = [];
+ var nextLogicalIndex = 16384; // pure-JS server entities sit above engine edicts
+ var nextClientsideKey = -2; // clientside-only entities (EntIndex() reports -1)
+
+ function native() { return OV && typeof OV.entCall === "function"; }
+ function ncall(idx, method, args) {
+ try { return OV.entCall(idx, method, args || []); }
+ catch (e) { if (OV && OV.warn) OV.warn("entCall " + method + ": " + (e && e.message)); return undefined; }
+ }
+
+ // NULL singleton
+ var NULL_ENTITY = null;
+
+ function Entity(key) {
+ // Calling Entity(n) as a function resolves an index (GMod Global.Entity).
+ if (!(this instanceof Entity)) {
+ var e = registry[key | 0];
+ return e && !e._r.removed ? e : NULL_ENTITY;
+ }
+ this.__ovEntity = true;
+ this._key = key;
+ this._r = {
+ class: "", model: "", pos: Vector(), ang: Angle(),
+ health: 0, maxHealth: 0, color: Color(255, 255, 255, 255), material: "",
+ moveType: 0, solid: 0, collisionGroup: 0, renderMode: 0, useType: 0,
+ name: "", keyvalues: {}, parent: null, owner: null,
+ spawned: false, removed: false, noDraw: false, trigger: false,
+ nw: Object.create(null), nwProxies: Object.create(null),
+ dt: Object.create(null), dtNotify: Object.create(null), dtSlots: Object.create(null),
+ removeCallbacks: [], deleteOnRemove: [],
+ nextThink: 0, createdAt: (OV && OV.time ? OV.time() : 0),
+ isNative: false, clientside: key < 0
+ };
+ }
+ Entity.__openvibe = true;
+
+ var P = Entity.prototype;
+
+ // ---- identity / lifecycle ----
+ P.IsValid = function () { return !this._r.removed && this !== NULL_ENTITY; };
+ P.isValid = P.IsValid;
+ P.EntIndex = function () { return this._r.clientside ? -1 : this._key; };
+ P.entIndex = P.EntIndex;
+ P.GetClass = function () { return this._r.class; };
+ P.GetModel = function () { return this._r.model || null; };
+ P.IsWorld = function () { return this._key === 0; };
+ P.GetCreationTime = function () { return this._r.createdAt; };
+ P.GetTable = function () { return this; };
+ P.IsPlayer = function () { return false; };
+ P.IsNPC = function () { return false; };
+ P.IsWeapon = function () { return false; };
+ P.IsScripted = function () { return !!this._ENT; };
+
+ P.Spawn = function () {
+ if (this._r.spawned || this._r.removed) return;
+ this._r.spawned = true;
+ if (this._r.isNative && native()) ncall(this._key, "spawn");
+ if (typeof this.Initialize === "function") {
+ try { this.Initialize(); } catch (e) { OV && OV.error && OV.error("ENT:Initialize " + this._r.class + ": " + (e && e.stack || e)); }
+ }
+ };
+ P.Activate = function () { if (this._r.isNative && native()) ncall(this._key, "activate"); };
+
+ P.Remove = function () {
+ if (this._r.removed) return;
+ if (pendingRemoval.indexOf(this) < 0) pendingRemoval.push(this);
+ };
+ P.IsMarkedForDeletion = function () { return pendingRemoval.indexOf(this) >= 0; };
+ P.CallOnRemove = function (id, fn) { this._r.removeCallbacks.push({ id: String(id), fn: fn }); };
+ P.RemoveCallOnRemove = function (id) {
+ this._r.removeCallbacks = this._r.removeCallbacks.filter(function (c) { return c.id !== String(id); });
+ };
+ P.DeleteOnRemove = function (ent) { this._r.deleteOnRemove.push(ent); };
+
+ // ---- transform ----
+ P.GetPos = function () {
+ if (this._r.isNative && native()) { var v = ncall(this._key, "getPos"); if (v) this._r.pos = v; }
+ return vcopy(this._r.pos);
+ };
+ P.SetPos = function (v) { this._r.pos = vcopy(v || {}); if (this._r.isNative && native()) ncall(this._key, "setPos", [this._r.pos.x, this._r.pos.y, this._r.pos.z]); };
+ P.GetAngles = function () {
+ if (this._r.isNative && native()) { var a = ncall(this._key, "getAngles"); if (a) this._r.ang = a; }
+ return acopy(this._r.ang);
+ };
+ P.SetAngles = function (a) { this._r.ang = acopy(a || {}); if (this._r.isNative && native()) ncall(this._key, "setAngles", [this._r.ang.p, this._r.ang.y, this._r.ang.r]); };
+ P.GetForward = function () {
+ var a = this.GetAngles(), p = a.p * Math.PI / 180, y = a.y * Math.PI / 180;
+ return Vector(Math.cos(p) * Math.cos(y), Math.cos(p) * Math.sin(y), -Math.sin(p));
+ };
+ P.GetVelocity = function () { return (this._r.isNative && native() && ncall(this._key, "getVelocity")) || Vector(); };
+ P.SetVelocity = function (v) { if (this._r.isNative && native()) ncall(this._key, "setVelocity", [v.x, v.y, v.z]); };
+ P.EyePos = function () { var p = this.GetPos(); return Vector(p.x, p.y, p.z + 64); };
+
+ // ---- appearance ----
+ P.SetModel = function (m) { this._r.model = String(m); if (this._r.isNative && native()) ncall(this._key, "setModel", [this._r.model]); };
+ P.SetModelScale = function (s) { if (this._r.isNative && native()) ncall(this._key, "setModelScale", [+s || 1]); };
+ P.SetColor = function (c) { this._r.color = c ? Color(c.r, c.g, c.b, c.a) : Color(255, 255, 255); if (this._r.isNative && native()) ncall(this._key, "setColor", [this._r.color.r, this._r.color.g, this._r.color.b, this._r.color.a]); };
+ P.GetColor = function () { return this._r.color; };
+ P.SetMaterial = function (m) { this._r.material = String(m || ""); if (this._r.isNative && native()) ncall(this._key, "setMaterial", [this._r.material]); };
+ P.GetMaterial = function () { return this._r.material; };
+ P.SetRenderMode = function (m) { this._r.renderMode = m | 0; if (this._r.isNative && native()) ncall(this._key, "setRenderMode", [m | 0]); };
+ P.SetNoDraw = function (b) { this._r.noDraw = !!b; if (this._r.isNative && native()) ncall(this._key, "setNoDraw", [!!b]); };
+ P.GetNoDraw = function () { return this._r.noDraw; };
+
+ // ---- health / damage ----
+ P.Health = function () {
+ if (this._r.isNative && native()) { var h = ncall(this._key, "health"); if (h !== undefined) this._r.health = h; }
+ return this._r.health;
+ };
+ P.SetHealth = function (v) { this._r.health = v | 0; if (this._r.isNative && native()) ncall(this._key, "setHealth", [v | 0]); };
+ P.GetMaxHealth = function () { return this._r.maxHealth; };
+ P.SetMaxHealth = function (v) { this._r.maxHealth = v | 0; if (this._r.isNative && native()) ncall(this._key, "setMaxHealth", [v | 0]); };
+ P.Alive = function () { return this.Health() > 0; };
+ P.TakeDamage = function (dmg, attacker, inflictor) {
+ if (!isServer) return;
+ var info = { damage: +dmg || 0, attacker: attacker || NULL_ENTITY, inflictor: inflictor || attacker || NULL_ENTITY,
+ GetDamage: function () { return this.damage; }, GetAttacker: function () { return this.attacker; },
+ GetInflictor: function () { return this.inflictor; }, SetDamage: function (d) { this.damage = +d || 0; },
+ ScaleDamage: function (s) { this.damage *= s; } };
+ var block = globalThis.hook ? hook.Run("EntityTakeDamage", this, info) : undefined;
+ if (block === true) return;
+ if (typeof this.OnTakeDamage === "function") {
+ try { this.OnTakeDamage(info); return; } catch (e) { OV && OV.error && OV.error("OnTakeDamage: " + (e && e.message)); }
+ }
+ if (this._r.isNative && native()) { ncall(this._key, "takeDamage", [info.damage, attacker ? attacker.EntIndex() : 0]); return; }
+ this.SetHealth(this.Health() - info.damage);
+ if (this.Health() <= 0 && !this._r.clientside) this.Remove();
+ };
+
+ // ---- physics / movement ----
+ P.PhysicsInit = function (solidType) {
+ if (this._r.isNative && native()) return !!ncall(this._key, "physicsInit", [solidType | 0]);
+ this._r.solid = solidType | 0;
+ return true;
+ };
+ P.PhysWake = function () { if (this._r.isNative && native()) ncall(this._key, "physWake"); };
+ P.GetPhysicsObject = function () {
+ var self = this;
+ return {
+ IsValid: function () { return self._r.solid !== 0 || (self._r.isNative && native() && !!ncall(self._key, "hasPhysics")); },
+ Wake: function () { self.PhysWake(); },
+ SetMass: function (m) { if (self._r.isNative && native()) ncall(self._key, "setMass", [+m || 1]); },
+ EnableMotion: function (b) { if (self._r.isNative && native()) ncall(self._key, "enableMotion", [!!b]); },
+ ApplyForceCenter: function (v) { if (self._r.isNative && native()) ncall(self._key, "applyForceCenter", [v.x, v.y, v.z]); }
+ };
+ };
+ P.SetMoveType = function (m) { this._r.moveType = m | 0; if (this._r.isNative && native()) ncall(this._key, "setMoveType", [m | 0]); };
+ P.GetMoveType = function () { return this._r.moveType; };
+ P.SetSolid = function (s) { this._r.solid = s | 0; if (this._r.isNative && native()) ncall(this._key, "setSolid", [s | 0]); };
+ P.GetSolid = function () { return this._r.solid; };
+ P.SetCollisionGroup = function (g) { this._r.collisionGroup = g | 0; if (this._r.isNative && native()) ncall(this._key, "setCollisionGroup", [g | 0]); };
+ P.GetCollisionGroup = function () { return this._r.collisionGroup; };
+
+ // ---- hierarchy ----
+ P.SetParent = function (parent) { this._r.parent = parent || null; if (this._r.isNative && native()) ncall(this._key, "setParent", [parent && parent.EntIndex ? parent.EntIndex() : 0]); };
+ P.GetParent = function () { return this._r.parent || NULL_ENTITY; };
+ P.GetChildren = function () {
+ var out = [], self = this;
+ Entity._each(function (e) { if (e._r.parent === self) out.push(e); });
+ return out;
+ };
+ P.SetOwner = function (owner) { this._r.owner = owner || null; if (this._r.isNative && native()) ncall(this._key, "setOwner", [owner && owner.EntIndex ? owner.EntIndex() : 0]); };
+ P.GetOwner = function () { return this._r.owner || NULL_ENTITY; };
+
+ // ---- keyvalues / IO / use ----
+ P.SetKeyValue = function (k, v) { this._r.keyvalues[String(k)] = String(v); if (this._r.isNative && native()) ncall(this._key, "setKeyValue", [String(k), String(v)]); if (typeof this.KeyValue === "function") { try { this.KeyValue(String(k), String(v)); } catch {} } };
+ P.GetKeyValues = function () { var out = {}; for (var k in this._r.keyvalues) out[k] = this._r.keyvalues[k]; return out; };
+ P.SetName = function (n) { this._r.name = String(n); };
+ P.GetName = function () { return this._r.name; };
+ P.Fire = function (input, param, delay) { if (this._r.isNative && native()) ncall(this._key, "fire", [String(input), String(param || ""), +delay || 0]); };
+ P.SetUseType = function (t) { this._r.useType = t | 0; };
+ P.SetTrigger = function (b) { this._r.trigger = !!b; };
+ P.Use = function (activator, caller, useType, value) {
+ if (typeof this._useHook === "function") { try { this._useHook(activator, caller, useType, value); } catch {} }
+ };
+ P.EmitSound = function (name) { if (this._r.isNative && native()) ncall(this._key, "emitSound", [String(name)]); };
+ P.NextThink = function (t) { this._r.nextThink = +t || 0; };
+ P.SetNextClientThink = P.NextThink;
+
+ // ---- networked vars (NW) ----
+ var NW_TYPES = ["Int", "Float", "Bool", "String", "Entity", "Vector", "Angle"];
+ var NW_DEFAULTS = { Int: 0, Float: 0, Bool: false, String: "", Entity: null, Vector: null, Angle: null };
+ function nwSet(ent, type, key, value) {
+ key = String(key);
+ var old = ent._r.nw[key];
+ ent._r.nw[key] = value;
+ var proxy = ent._r.nwProxies[key];
+ if (proxy) { try { proxy(ent, key, old, value); } catch {} }
+ if (isServer && globalThis.net && net.__openvibe && OV && OV.netEmit) {
+ var wire = value;
+ if (type === "Entity") wire = value && value.EntIndex ? value.EntIndex() : 0;
+ try {
+ net.Start("__ovnw");
+ net.WriteInt(ent.EntIndex());
+ net.WriteString(type);
+ net.WriteString(key);
+ net.WriteType(wire);
+ net.Broadcast();
+ } catch (e) { /* net not ready during early load */ }
+ }
+ }
+ function nwGet(ent, type, key, fallback) {
+ var v = ent._r.nw[String(key)];
+ if (v === undefined) return fallback !== undefined ? fallback : NW_DEFAULTS[type];
+ return v;
+ }
+ NW_TYPES.forEach(function (type) {
+ P["SetNW" + type] = function (key, value) { nwSet(this, type, key, value); };
+ P["GetNW" + type] = function (key, fallback) { return nwGet(this, type, key, fallback); };
+ P["SetNW2" + type] = P["SetNW" + type];
+ P["GetNW2" + type] = P["GetNW" + type];
+ });
+ P.SetNWVarProxy = function (key, fn) { this._r.nwProxies[String(key)] = fn; };
+ P.GetNWVarTable = function () { var out = {}; for (var k in this._r.nw) out[k] = this._r.nw[k]; return out; };
+
+ // ---- DTVars (NetworkVar) ----
+ P.NetworkVar = function (type, slot, name, extended) {
+ var ent = this;
+ name = String(name);
+ ent._r.dtSlots[name] = { type: String(type), slot: slot | 0 };
+ if (ent._r.dt[name] === undefined) ent._r.dt[name] = NW_DEFAULTS[type];
+ ent["Get" + name] = function () { return ent._r.dt[name]; };
+ ent["Set" + name] = function (v) {
+ var old = ent._r.dt[name];
+ ent._r.dt[name] = v;
+ var notify = ent._r.dtNotify[name];
+ if (notify) { try { notify(ent, name, old, v); } catch {} }
+ if (isServer && globalThis.net && net.__openvibe && OV && OV.netEmit) {
+ var wire = type === "Entity" ? (v && v.EntIndex ? v.EntIndex() : 0) : v;
+ try {
+ net.Start("__ovdt");
+ net.WriteInt(ent.EntIndex());
+ net.WriteString(name);
+ net.WriteType(wire);
+ net.Broadcast();
+ } catch (e) { /* net not ready */ }
+ }
+ };
+ };
+ P.NetworkVarNotify = function (name, fn) { this._r.dtNotify[String(name)] = fn; };
+ P.GetNetworkVars = function () { var out = {}; for (var k in this._r.dt) out[k] = this._r.dt[k]; return out; };
+
+ // ---- internal registry api (used by ents.js / player.js) ----
+ Entity._register = function (ent) { registry[ent._key] = ent; };
+ Entity._unregister = function (ent) { delete registry[ent._key]; };
+ Entity._each = function (fn) {
+ for (var k in registry) { var e = registry[k]; if (e && !e._r.removed) fn(e); }
+ };
+ Entity._get = function (key) { var e = registry[key | 0]; return e && !e._r.removed ? e : NULL_ENTITY; };
+ Entity._allocLogical = function () { return nextLogicalIndex++; };
+ Entity._allocClientside = function () { return nextClientsideKey--; };
+ Entity._native = native;
+ Entity._ncall = ncall;
+ Entity._dist2 = dist2;
+ Entity._pendingRemoval = pendingRemoval;
+
+ // Actual removal, deferred to end of tick (GMod behavior).
+ function reallyRemove(ent) {
+ if (ent._r.removed) return;
+ ent._r.removeCallbacks.forEach(function (c) { try { c.fn(ent); } catch {} });
+ if (typeof ent.OnRemove === "function") { try { ent.OnRemove(false); } catch {} }
+ ent._r.removed = true;
+ if (ent._r.isNative && native()) ncall(ent._key, "remove");
+ Entity._unregister(ent);
+ if (globalThis.hook) { try { hook.Run("EntityRemoved", ent, false); } catch {} }
+ ent._r.deleteOnRemove.forEach(function (other) { if (other && other.Remove) other.Remove(); });
+ }
+ Entity._flushRemovals = function () {
+ if (!pendingRemoval.length) return;
+ var batch = pendingRemoval.splice(0, pendingRemoval.length);
+ batch.forEach(reallyRemove);
+ };
+
+ // ---- NULL entity ----
+ NULL_ENTITY = new Entity(0);
+ NULL_ENTITY._r.removed = true;
+ NULL_ENTITY._r.class = "";
+ NULL_ENTITY.IsValid = function () { return false; };
+ NULL_ENTITY.isValid = NULL_ENTITY.IsValid;
+ globalThis.NULL = NULL_ENTITY;
+
+ // ---- world entity (index 0 semantics kept simple) ----
+ globalThis.Entity = Entity;
+
+ // ---- replication receivers (both realms register; only client applies) ----
+ function ensureShellEntity(idx) {
+ var e = Entity._get(idx);
+ if (e && e.IsValid()) return e;
+ var shell = new Entity(idx);
+ shell._r.spawned = true;
+ Entity._register(shell);
+ return shell;
+ }
+ // net.js loads before entity.js in the bootstrap order, so this runs
+ // immediately in practice.
+ function registerSyncReceivers() {
+ if (!globalThis.net || !net.__openvibe) return;
+ if (isServer) {
+ if (globalThis.util && util.AddNetworkString) {
+ util.AddNetworkString("__ovnw");
+ util.AddNetworkString("__ovdt");
+ util.AddNetworkString("__ovchunk");
+ }
+ return;
+ }
+ net.Receive("__ovnw", function () {
+ var idx = net.ReadInt(), type = net.ReadString(), key = net.ReadString(), v = net.ReadType();
+ var ent = ensureShellEntity(idx);
+ if (type === "Entity") v = Entity._get(v | 0);
+ var old = ent._r.nw[key];
+ ent._r.nw[key] = v;
+ var proxy = ent._r.nwProxies[key];
+ if (proxy) { try { proxy(ent, key, old, v); } catch {} }
+ });
+ net.Receive("__ovdt", function () {
+ var idx = net.ReadInt(), name = net.ReadString(), v = net.ReadType();
+ var ent = ensureShellEntity(idx);
+ var slot = ent._r.dtSlots[name];
+ if (slot && slot.type === "Entity") v = Entity._get(v | 0);
+ var old = ent._r.dt[name];
+ ent._r.dt[name] = v;
+ var notify = ent._r.dtNotify[name];
+ if (notify) { try { notify(ent, name, old, v); } catch {} }
+ });
+ }
+ registerSyncReceivers();
+
+ if (OV && OV.log) OV.log("entity system ready (realm=" + (isServer ? "server" : "client") + ")");
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
+// OpenVibe ents + scripted_ents libraries — GMod semantics.
+// https://wiki.facepunch.com/gmod/ents https://wiki.facepunch.com/gmod/Scripted_Entities
+//
+// scripted_ents.Register(ENT, class) supports Base prototype chains and
+// hot-patches live instances on re-register (GMod auto-refresh behavior).
+(function () {
+ if (globalThis.ents && globalThis.ents.__openvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+ var Entity = globalThis.Entity;
+
+ // ---- scripted entity registry ----
+ var stored = Object.create(null); // class -> { t: def, proto, instances: [] }
+
+ function buildProto(def, className) {
+ // Prototype chain: Entity.prototype <- base proto <- def
+ var baseProto = Entity.prototype;
+ if (def.Base && def.Base !== className) {
+ var baseEntry = stored[def.Base];
+ if (baseEntry) baseProto = baseEntry.proto;
+ else if (OV && OV.warn) OV.warn("scripted_ents: base '" + def.Base + "' of '" + className + "' not registered yet");
+ }
+ var proto = Object.create(baseProto);
+ for (var k in def) proto[k] = def[k];
+ proto.ClassName = className;
+ proto.BaseClass = baseProto;
+ return proto;
+ }
+
+ var scripted_ents = {
+ __openvibe: true,
+ Register: function (def, className) {
+ className = String(className);
+ if (!def || typeof def !== "object") throw new Error("scripted_ents.Register requires an ENT table");
+ var proto = buildProto(def, className);
+ var entry = stored[className];
+ if (entry) {
+ // Hot-patch: live instances jump to the new prototype; OnReloaded fires.
+ entry.t = def;
+ entry.proto = proto;
+ entry.instances = entry.instances.filter(function (e) { return e.IsValid(); });
+ entry.instances.forEach(function (e) {
+ Object.setPrototypeOf(e, proto);
+ if (typeof e.OnReloaded === "function") { try { e.OnReloaded(); } catch {} }
+ });
+ // Dependent classes need their chains rebuilt too.
+ for (var cls in stored) {
+ if (cls !== className && stored[cls].t.Base === className) {
+ scripted_ents.Register(stored[cls].t, cls);
+ }
+ }
+ } else {
+ stored[className] = { t: def, proto: proto, instances: [] };
+ }
+ if (globalThis.baseclass) baseclass.Set("entity_" + className, proto);
+ return stored[className];
+ },
+ GetStored: function (className) { return stored[String(className)] || null; },
+ Get: function (className) {
+ var entry = stored[String(className)];
+ if (!entry) return null;
+ var out = {};
+ var p = entry.proto;
+ // flatten the chain (GMod returns a base-merged copy)
+ var keys = [];
+ for (var k in p) keys.push(k);
+ keys.forEach(function (key) { out[key] = p[key]; });
+ return out;
+ },
+ GetList: function () { var out = {}; for (var k in stored) out[k] = stored[k].t; return out; },
+ GetType: function (className) { var e = stored[String(className)]; return e ? e.t.Type || "anim" : null; },
+ IsBasedOn: function (className, baseName) {
+ var cur = String(className);
+ var guard = 0;
+ while (cur && guard++ < 32) {
+ if (cur === String(baseName)) return true;
+ var e = stored[cur];
+ cur = e && e.t.Base;
+ }
+ return false;
+ },
+ GetMember: function (className, key) {
+ var e = stored[String(className)];
+ return e ? e.proto[key] : undefined;
+ },
+ _stored: stored
+ };
+
+ // ---- construction ----
+ function construct(className, key, clientside) {
+ var ent = new Entity(key);
+ ent._r.class = className;
+ ent._r.clientside = !!clientside;
+ var entry = stored[className];
+ if (entry) {
+ Object.setPrototypeOf(ent, entry.proto);
+ ent._ENT = entry.t;
+ entry.instances.push(ent);
+ }
+ Entity._register(ent);
+ if (typeof ent.SetupDataTables === "function") {
+ try { ent.SetupDataTables(); } catch (e) { OV && OV.error && OV.error("SetupDataTables " + className + ": " + (e && e.message)); }
+ }
+ if (globalThis.hook) { try { hook.Run("OnEntityCreated", ent); } catch {} }
+ return ent;
+ }
+
+ var ents = {
+ __openvibe: true,
+
+ Create: function (className) {
+ if (!isServer) { OV && OV.warn && OV.warn("ents.Create is server-only; use ents.CreateClientside"); return globalThis.NULL; }
+ className = String(className);
+ var entry = stored[className];
+ var key;
+ if (Entity._native()) {
+ // Real engine entity (scripted classes become logical + native anchor
+ // when the class is unknown to the engine).
+ var res = OV.entCreate ? OV.entCreate(className) : null;
+ if (res && res.entIndex > 0) {
+ key = res.entIndex | 0;
+ var ent = construct(className, key, false);
+ ent._r.isNative = true;
+ return ent;
+ }
+ if (!entry) return globalThis.NULL; // unknown engine class and not scripted
+ }
+ if (!entry && !Entity._native()) {
+ // Pure-JS backend: allow logical entities for any class so gamemode
+ // logic and tests behave; scripted classes get their prototypes.
+ }
+ key = Entity._allocLogical();
+ return construct(className, key, false);
+ },
+
+ CreateClientside: function (className) {
+ if (isServer) { OV && OV.warn && OV.warn("ents.CreateClientside is client-only"); return globalThis.NULL; }
+ return construct(String(className), Entity._allocClientside(), true);
+ },
+
+ GetByIndex: function (idx) { return Entity._get(idx | 0); },
+
+ GetAll: function () {
+ var out = [];
+ Entity._each(function (e) { out.push(e); });
+ if (globalThis.player && player.GetAll) {
+ player.GetAll().forEach(function (p) { if (out.indexOf(p) < 0) out.push(p); });
+ }
+ return out;
+ },
+
+ Iterator: function () {
+ var all = ents.GetAll(), i = 0;
+ return function () { return i < all.length ? [i, all[i++]] : null; };
+ },
+
+ GetCount: function () { return ents.GetAll().length; },
+
+ FindByClass: function (pattern) {
+ pattern = String(pattern);
+ var rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
+ return ents.GetAll().filter(function (e) { return rx.test(e.GetClass()); });
+ },
+
+ FindByName: function (pattern) {
+ pattern = String(pattern);
+ var rx = new RegExp("^" + pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*") + "$");
+ return ents.GetAll().filter(function (e) { return rx.test(e.GetName ? e.GetName() : ""); });
+ },
+
+ FindByModel: function (model) {
+ model = String(model);
+ return ents.GetAll().filter(function (e) { return e.GetModel && e.GetModel() === model; });
+ },
+
+ FindInSphere: function (origin, radius) {
+ var r2 = (+radius || 0) * (+radius || 0);
+ return ents.GetAll().filter(function (e) { return Entity._dist2(e.GetPos(), origin) <= r2; });
+ },
+
+ FindInBox: function (mins, maxs) {
+ return ents.GetAll().filter(function (e) {
+ var p = e.GetPos();
+ return p.x >= mins.x && p.x <= maxs.x && p.y >= mins.y && p.y <= maxs.y && p.z >= mins.z && p.z <= maxs.z;
+ });
+ },
+
+ FireTargets: function (name, activator, caller, useType, value) {
+ ents.FindByName(name).forEach(function (e) { e.Use(activator, caller, useType, value); });
+ }
+ };
+
+ // ---- Think pump: entity Think scheduling + deferred removals ----
+ if (globalThis.hook && typeof hook.Add === "function") {
+ hook.Add("Think", "OpenVibeEntityThink", function () {
+ var now = OV && OV.time ? OV.time() : 0;
+ Entity._each(function (e) {
+ if (!e._ENT || typeof e.Think !== "function" || !e._r.spawned) return;
+ if (e._r.nextThink && now < e._r.nextThink) return;
+ e._r.nextThink = 0;
+ try { e.Think(); } catch (err) { OV && OV.error && OV.error("ENT:Think " + e.GetClass() + ": " + (err && err.message)); }
+ });
+ Entity._flushRemovals();
+ return undefined;
+ });
+ }
+
+ globalThis.ents = ents;
+ globalThis.scripted_ents = scripted_ents;
+
+ if (OV && OV.log) OV.log("ents library ready");
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+// OpenVibe file library — AddCSLuaFile/include equivalents + client script sync.
+// https://wiki.facepunch.com/gmod/Global.AddCSLuaFile
+//
+// AddCSJSFile(path?) marks a js/-relative file for download to clients
+// (server-only effect). These paths are auto-networked without marking:
+// js/autorun/*.js, js/autorun/client/*.js,
+// js/gamemodes/*/{manifest.json,shared.js,client.js},
+// js/entities/** ,
+// addons/*/{shared.js,client.js} + addon autorun/entities
+//
+// Sync protocol (net messages, server->client unless noted):
+// __ovfs_manifest [{p, h, s}] full manifest {path, hash, size}
+// __ovfs_request [paths] (client->server, rate limited)
+// __ovfs_data path, content one file (net library auto-chunks)
+// -> client caches under js/ov_downloads/<path> (OV.writeFile) and fires
+// the "OVFilesSynced" hook when the diff is satisfied.
+(function () {
+ if (globalThis.file && globalThis.file.__ovOpenvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+
+ var marked = Object.create(null); // js-relative path -> true (explicit AddCSJSFile)
+ var currentFile = null; // set by the loader while a file executes
+
+ function normalize(p) {
+ var parts = String(p).replace(/\\/g, "/").split("/");
+ var out = [];
+ for (var i = 0; i < parts.length; i++) {
+ var s = parts[i];
+ if (s === "" || s === ".") continue;
+ if (s === "..") { out.pop(); continue; }
+ out.push(s);
+ }
+ return out.join("/");
+ }
+
+ function dirname(p) {
+ var i = p.lastIndexOf("/");
+ return i <= 0 ? "" : p.slice(0, i);
+ }
+
+ // Resolve a script path GMod-style: relative to js/ first, then relative to
+ // the currently executing file's directory.
+ function resolveScript(p) {
+ p = normalize(p);
+ var candidates = [];
+ if (p.indexOf("js/") === 0 || p.indexOf("addons/") === 0) candidates.push(p);
+ else {
+ candidates.push("js/" + p);
+ if (currentFile) candidates.push(normalize(dirname(currentFile) + "/" + p));
+ }
+ for (var i = 0; i < candidates.length; i++) {
+ if (OV && OV.fileExists && OV.fileExists(candidates[i])) return candidates[i];
+ }
+ // client: fall back to the synced download cache
+ if (!isServer) {
+ for (var j = 0; j < candidates.length; j++) {
+ var dl = "js/ov_downloads/" + candidates[j];
+ if (OV && OV.fileExists && OV.fileExists(dl)) return dl;
+ }
+ }
+ return null;
+ }
+
+ function readScript(p) {
+ var full = resolveScript(p);
+ return full ? { path: full, code: OV.readFile(full) } : null;
+ }
+
+ // ---- AddCSJSFile ----
+ function addCSJSFile(p) {
+ if (!isServer) return; // no-op on the client (safe in shared files)
+ var path = p === undefined ? currentFile : resolveScript(p);
+ if (!path && p !== undefined) path = normalize(String(p).indexOf("js/") === 0 ? String(p) : "js/" + String(p));
+ if (path) marked[path] = true;
+ }
+
+ // ---- include ----
+ function include(p) {
+ var found = readScript(p);
+ if (!found || found.code == null) {
+ var msg = "include('" + p + "'): file doesn't exist";
+ if (OV && OV.error) OV.error(msg); else throw new Error(msg);
+ return undefined;
+ }
+ var prev = currentFile;
+ currentFile = found.path;
+ try {
+ return (0, eval)(found.code + "\n//# sourceURL=" + found.path);
+ } catch (e) {
+ if (OV && OV.error) OV.error("include('" + p + "'): " + (e && e.stack ? e.stack : e));
+ return undefined;
+ } finally {
+ currentFile = prev;
+ }
+ }
+
+ // ---- auto-networked path discovery ----
+ function listJs(dir) {
+ if (!OV || !OV.listDir) return [];
+ var names = OV.listDir(dir, "*") || [];
+ return names.filter(function (n) { return /\.(js|json)$/.test(n); }).map(function (n) { return dir + "/" + n; });
+ }
+
+ function autoNetworkedFiles() {
+ var out = [];
+ out.push.apply(out, listJs("js/autorun"));
+ out.push.apply(out, listJs("js/autorun/client"));
+ var modes = (OV && OV.listDir ? OV.listDir("js/gamemodes", "*") : []) || [];
+ modes.forEach(function (m) {
+ if (m.charAt(0) === ".") return;
+ ["manifest.json", "shared.js", "client.js"].forEach(function (f) {
+ var p = "js/gamemodes/" + m + "/" + f;
+ if (OV.fileExists(p)) out.push(p);
+ });
+ });
+ var entities = (OV && OV.listDir ? OV.listDir("js/entities", "*") : []) || [];
+ entities.forEach(function (e) {
+ if (e.charAt(0) === ".") return;
+ var single = "js/entities/" + e;
+ if (/\.js$/.test(e) && OV.fileExists(single)) { out.push(single); return; }
+ ["shared.js", "cl_init.js"].forEach(function (f) {
+ var p = "js/entities/" + e + "/" + f;
+ if (OV.fileExists(p)) out.push(p);
+ });
+ });
+ var addons = (OV && OV.listDir ? OV.listDir("addons", "*") : []) || [];
+ addons.forEach(function (a) {
+ if (a === "node_modules" || a.charAt(0) === ".") return;
+ ["shared.js", "client.js", "addon.json"].forEach(function (f) {
+ var p = "addons/" + a + "/" + f;
+ if (OV.fileExists(p)) out.push(p);
+ });
+ });
+ return out;
+ }
+
+ function buildManifest() {
+ var files = autoNetworkedFiles();
+ for (var p in marked) if (files.indexOf(p) < 0) files.push(p);
+ return files.map(function (p) {
+ var code = OV.readFile(p);
+ return { p: p, h: code != null && globalThis.util ? util.CRC(code) : "0", s: code ? code.length : 0 };
+ }).filter(function (f) { return f.s > 0; });
+ }
+
+ var file = {
+ __ovOpenvibe: true,
+ Exists: function (p) { return !!resolveScript(p); },
+ Read: function (p) { var f = readScript(p); return f ? f.code : null; },
+ Write: function (p, content) {
+ // Client-side cache writes only (download dir), mirroring GMod's data/ jail.
+ if (OV && OV.writeFile) return OV.writeFile("js/ov_downloads/" + normalize(p), String(content));
+ return false;
+ },
+ BuildClientManifest: buildManifest,
+ _setCurrent: function (p) { currentFile = p ? normalize(p) : null; },
+ _getCurrent: function () { return currentFile; },
+ _resolve: resolveScript,
+ _marked: marked
+ };
+
+ globalThis.file = file;
+ globalThis.include = include;
+ globalThis.AddCSJSFile = addCSJSFile;
+ globalThis.AddCSLuaFile = addCSJSFile; // muscle-memory alias
+
+ // ---- sync protocol ----
+ function poolNames() {
+ if (isServer && globalThis.util && util.AddNetworkString) {
+ util.AddNetworkString("__ovfs_manifest");
+ util.AddNetworkString("__ovfs_request");
+ util.AddNetworkString("__ovfs_data");
+ }
+ }
+ poolNames();
+
+ if (globalThis.net && net.__openvibe) {
+ if (isServer) {
+ // Send the manifest shortly after a player finishes joining.
+ if (globalThis.hook) {
+ hook.Add("PlayerInitialSpawn", "OpenVibeFileSync", function (ply) {
+ var send = function () {
+ try {
+ net.Start("__ovfs_manifest");
+ net.WriteTable(buildManifest());
+ net.Send(ply);
+ } catch (e) { OV && OV.warn && OV.warn("file sync manifest: " + (e && e.message)); }
+ };
+ if (globalThis.timer && timer.simple) timer.simple(2, send); else send();
+ return undefined;
+ });
+ }
+ net.SetRateLimit("__ovfs_request", 10);
+ net.Receive("__ovfs_request", function (len, ply) {
+ if (!ply) return;
+ var paths = net.ReadTable(true) || [];
+ if (!Array.isArray(paths)) return;
+ var manifest = buildManifest();
+ var allowed = {};
+ manifest.forEach(function (f) { allowed[f.p] = true; });
+ paths.slice(0, 64).forEach(function (p) {
+ p = normalize(String(p));
+ if (!allowed[p]) return; // only networked files may be requested
+ var code = OV.readFile(p);
+ if (code == null) return;
+ net.Start("__ovfs_data");
+ net.WriteString(p);
+ net.WriteString(code);
+ net.Send(ply);
+ });
+ });
+ } else {
+ var pendingSync = null;
+ net.Receive("__ovfs_manifest", function () {
+ var manifest = net.ReadTable(true) || [];
+ var missing = [];
+ manifest.forEach(function (f) {
+ var local = OV.readFile(f.p);
+ var cached = OV.readFile("js/ov_downloads/" + f.p);
+ var have = local != null ? util.CRC(local) : (cached != null ? util.CRC(cached) : null);
+ if (have !== f.h) missing.push(f.p);
+ });
+ if (!missing.length) {
+ if (globalThis.hook) hook.Run("OVFilesSynced", []);
+ return;
+ }
+ pendingSync = { missing: {}, count: missing.length };
+ missing.forEach(function (p) { pendingSync.missing[p] = true; });
+ net.Start("__ovfs_request");
+ net.WriteTable(missing, true);
+ net.SendToServer();
+ });
+ net.Receive("__ovfs_data", function () {
+ var p = net.ReadString();
+ var content = net.ReadString();
+ file.Write(p, content);
+ if (pendingSync && pendingSync.missing[p]) {
+ delete pendingSync.missing[p];
+ pendingSync.count--;
+ if (pendingSync.count <= 0) {
+ pendingSync = null;
+ if (globalThis.hook) hook.Run("OVFilesSynced", []);
+ }
+ }
+ });
+ }
+ }
+
+ if (OV && OV.log) OV.log("file library ready (AddCSJSFile/include)");
+})();
@@ -1,6 +1,11 @@
@@ -1,6 +1,11 @@
+// OpenVibe gamemode library — GMod gamemode.Register / DeriveGamemode /
+// baseclass parity, layered on prototype inheritance.
+// https://wiki.facepunch.com/gmod/Gamemode_Creation
(function () {
(function () {
- let base = null;
- let current = null;
+ let base = null; // the "base" gamemode table
+ let current = null; // active GAMEMODE
+ const registered = Object.create(null); // name -> gm table
+ const baseclasses = Object.create(null); // "gamemode_<name>" -> table
function publish(gm) {
function publish(gm) {
current = gm;
current = gm;
@@ -11,58 +16,96 @@
@@ -11,58 +16,96 @@
function derive(gm, parent) {
function derive(gm, parent) {
if (!gm || typeof gm !== "object") throw new Error("gamemode.derive(gm) requires an object");
if (!gm || typeof gm !== "object") throw new Error("gamemode.derive(gm) requires an object");
-
const prototype = parent || base || null;
const prototype = parent || base || null;
const out = prototype ? Object.create(prototype) : {};
const out = prototype ? Object.create(prototype) : {};
for (const key of Object.keys(gm)) out[key] = gm[key];
for (const key of Object.keys(gm)) out[key] = gm[key];
return out;
return out;
}
}
+ // gamemode.Register(GM, name, derivedName) — wires inheritance, stores the
+ // table under the name, publishes baseclass. Mirrors the engine step that
+ // runs after gamemode files finish executing.
+ function register(gm, name, derivedName) {
+ if (!gm || typeof gm !== "object") throw new Error("gamemode.Register(gm, name) requires an object");
+ name = String(name || gm.mode || gm.FolderName || "unnamed");
+
+ let parent = null;
+ if (derivedName && registered[derivedName]) parent = registered[derivedName];
+ else if (derivedName === "base" || !derivedName) parent = base;
+
+ const out = (parent && Object.getPrototypeOf(gm) !== parent) ? derive(gm, parent) : gm;
+ out.FolderName = out.FolderName || name;
+ out.DerivedFrom = derivedName || (parent === base && name !== "base" ? "base" : undefined);
+
+ registered[name] = out;
+ baseclasses["gamemode_" + name] = out;
+ if (name === "base") base = out;
+ return out;
+ }
+
function set(gm, options) {
function set(gm, options) {
if (!gm || typeof gm !== "object") throw new Error("gamemode.set(gm) requires an object");
if (!gm || typeof gm !== "object") throw new Error("gamemode.set(gm) requires an object");
-
const opts = options || {};
const opts = options || {};
if (!base || opts.base === true || gm.mode === "base") {
if (!base || opts.base === true || gm.mode === "base") {
- base = gm;
- return publish(gm);
+ base = register(gm, gm.mode || "base");
+ return publish(base);
}
}
-
- return publish(derive(gm, base));
+ return publish(register(gm, gm.mode || gm.FolderName, gm.DerivedFrom || "base"));
}
}
function setBase(gm) {
function setBase(gm) {
if (!gm || typeof gm !== "object") throw new Error("gamemode.setBase(gm) requires an object");
if (!gm || typeof gm !== "object") throw new Error("gamemode.setBase(gm) requires an object");
- base = gm;
- if (!current) publish(gm);
- return gm;
+ base = register(gm, "base");
+ if (!current) publish(base);
+ return base;
}
}
- function get() {
- return current;
- }
+ function get() { return current; }
+ function getBase() { return base; }
+ function getRegistered(name) { return name ? registered[String(name)] || null : Object.keys(registered); }
- function getBase() {
- return base;
+ // gamemode.call — the C++ bridge entry point for every engine-fired hook.
+ // Wraps native player handles into framework Player objects when the player
+ // library is loaded, then dispatches through the hook library.
+ function wrapArg(a) {
+ if (a && typeof a === "object" && !a.__ovEntity &&
+ typeof a.userId === "function" && globalThis.Player &&
+ typeof Player.fromNative === "function") {
+ try { return Player.fromNative(a); } catch { return a; }
+ }
+ return a;
}
}
function call(name, ...args) {
function call(name, ...args) {
- return hook.call(name, current, ...args);
+ const wrapped = args.map(wrapArg);
+ return hook.call(name, current, ...wrapped);
}
}
globalThis.gamemode = {
globalThis.gamemode = {
- set,
- setBase,
- derive,
- get,
- getBase,
- call,
+ set, setBase, derive, get, getBase, call,
+ register,
Set: set,
Set: set,
SetBase: setBase,
SetBase: setBase,
Derive: derive,
Derive: derive,
Get: get,
Get: get,
GetBase: getBase,
GetBase: getBase,
- Call: call
+ Call: call,
+ Register: register,
+ GetRegistered: getRegistered
+ };
+
+ // DeriveGamemode("name") — GMod global; records the parent for the gamemode
+ // file currently executing. gamemode.set applies it via GM.DerivedFrom.
+ globalThis.DeriveGamemode = function (parentName) {
+ if (globalThis.GM && typeof GM === "object") GM.DerivedFrom = String(parentName);
+ return parentName;
+ };
+
+ // baseclass library (https://wiki.facepunch.com/gmod/baseclass)
+ globalThis.baseclass = {
+ Set: function (name, tbl) { baseclasses[String(name)] = tbl; },
+ Get: function (name) { return baseclasses[String(name)] || null; }
};
};
globalThis.GM = null;
globalThis.GM = null;
@@ -1,5 +1,15 @@
@@ -1,5 +1,15 @@
+// OpenVibe hook library — GMod hook semantics.
+// https://wiki.facepunch.com/gmod/Hook_Library_Usage
+//
+// - hook.Add(event, id, fn): id is a string OR an object with IsValid();
+// re-adding with the same (event, id) replaces the hook.
+// - Object identifiers: if !IsValid(id) at call time the hook is silently
+// removed; otherwise the identifier is prepended as the first argument.
+// - hook.Call(event, gm, ...): registered hooks run first (insertion order);
+// the first hook returning anything other than undefined short-circuits
+// (false counts as a result); otherwise the gamemode method runs.
(function () {
(function () {
- const buckets = Object.create(null);
+ const buckets = Object.create(null); // event -> [{id, obj, fn}]
function ensure(name) {
function ensure(name) {
name = String(name || "");
name = String(name || "");
@@ -9,54 +19,71 @@
@@ -9,54 +19,71 @@
function logError(name, id, err) {
function logError(name, id, err) {
const message = err && err.stack ? err.stack : String(err);
const message = err && err.stack ? err.stack : String(err);
+ const label = typeof id === "string" ? id : "<object>";
if (globalThis.OV && typeof OV.error === "function") {
if (globalThis.OV && typeof OV.error === "function") {
- OV.error(`hook ${name}/${id}: ${message}`);
+ OV.error(`hook ${name}/${label}: ${message}`);
} else {
} else {
- console.error(`hook ${name}/${id}: ${message}`);
+ console.error(`hook ${name}/${label}: ${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");
- }
+ function objValid(obj) {
+ if (obj == null) return false;
+ if (typeof obj.IsValid === "function") { try { return !!obj.IsValid(); } catch { return false; } }
+ if (typeof obj.isValid === "function") { try { return !!obj.isValid(); } catch { return false; } }
+ return true; // plain object without IsValid stays registered (GMod requires IsValid, we are lenient)
+ }
- 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 add(name, id, fn) {
+ if (!name || id == null || typeof fn !== "function") {
+ throw new Error("hook.Add(name, id, fn) requires a hook name, id, and function");
+ }
+ const isObj = typeof id === "object" || typeof id === "function";
+ if (!isObj && typeof id !== "string" && typeof id !== "number") {
+ throw new Error("hook.Add identifier must be a string or an object with IsValid()");
+ }
+ const list = ensure(name);
+ const key = isObj ? id : String(id);
+ for (let i = list.length - 1; i >= 0; --i) {
+ if (list[i].key === key) list.splice(i, 1);
+ }
+ list.push({ key, id: isObj ? "<object>" : String(id), obj: isObj ? id : null, fn });
}
}
function remove(name, id) {
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);
- }
+ const list = buckets[String(name || "")];
+ if (!list) return;
+ const isObj = typeof id === "object" || typeof id === "function";
+ const key = isObj ? id : String(id == null ? "" : id);
+ for (let i = list.length - 1; i >= 0; --i) {
+ if (list[i].key === key) list.splice(i, 1);
+ }
}
}
function call(name, gm, ...args) {
function call(name, gm, ...args) {
- const list = buckets[String(name || "")];
- if (list) {
- const snapshot = list.slice();
- for (const item of snapshot) {
+ const list = buckets[String(name || "")];
+ if (list && list.length) {
+ const snapshot = list.slice();
+ for (const item of snapshot) {
+ if (item.obj) {
+ if (!objValid(item.obj)) { remove(name, item.obj); continue; }
+ try {
+ const result = item.fn(item.obj, ...args);
+ if (result !== undefined) return result;
+ } catch (err) { logError(name, item.id, err); }
+ } else {
try {
try {
const result = item.fn(...args);
const result = item.fn(...args);
if (result !== undefined) return result;
if (result !== undefined) return result;
- } catch (err) {
- logError(name, item.id, err);
- }
+ } catch (err) { logError(name, item.id, err); }
}
}
}
}
+ }
- if (gm && typeof gm[name] === "function") {
- return gm[name](...args);
- }
-
- return undefined;
+ if (gm && typeof gm[name] === "function") {
+ return gm[name](...args);
+ }
+ return undefined;
}
}
function run(name, ...args) {
function run(name, ...args) {
@@ -66,24 +93,21 @@
@@ -66,24 +93,21 @@
return call(name, gm, ...args);
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;
+ function getTable(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 = {
globalThis.hook = {
- add,
- remove,
- run,
- call,
- list,
+ add, remove, run, call,
+ list: getTable,
Add: add,
Add: add,
Remove: remove,
Remove: remove,
Run: run,
Run: run,
Call: call,
Call: call,
- GetTable: list
+ GetTable: getTable
};
};
})();
})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,181 @@
@@ -0,0 +1,181 @@
+// OpenVibe loader — GMod Lua_Loading_Order equivalent for the js/ tree.
+// https://wiki.facepunch.com/gmod/Lua_Loading_Order
+//
+// Per realm: autorun (shared, then realm dir; alphabetical) -> gamemode chain
+// (base first, then the active mode: shared -> realm entry) -> scripted
+// entities -> addons. Files are deduped, so hosts that pre-load gamemode
+// files (the older embedded C++ path) can still call loadAll() safely.
+//
+// Also provides the console-exec entry points:
+// OVLoader.runString(code, id) — js_run / js_run_cl
+// OVLoader.openScript(path) — js_openscript / js_openscript_cl
+(function () {
+ if (globalThis.OVLoader && globalThis.OVLoader.__openvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+ var realmDir = isServer ? "server" : "client";
+ var loadedFiles = Object.create(null); // path -> true
+
+ function log(m) { if (OV && OV.log) OV.log("[loader] " + m); }
+ function warn(m) { if (OV && OV.warn) OV.warn("[loader] " + m); }
+
+ function execFile(path, force) {
+ if (!force && loadedFiles[path]) return true;
+ var code = OV.readFile(path);
+ if (code == null) return false;
+ loadedFiles[path] = true;
+ var prev = globalThis.file ? file._getCurrent() : null;
+ if (globalThis.file) file._setCurrent(path);
+ try {
+ (0, eval)(code + "\n//# sourceURL=" + path);
+ return true;
+ } catch (e) {
+ warn("failed " + path + ": " + (e && e.stack ? e.stack : e));
+ return false;
+ } finally {
+ if (globalThis.file) file._setCurrent(prev);
+ }
+ }
+
+ function listSorted(dir, filterRx) {
+ if (!OV || !OV.listDir) return [];
+ var names = (OV.listDir(dir, "*") || []).slice();
+ names.sort();
+ return names.filter(function (n) { return filterRx.test(n); });
+ }
+
+ // ---- autorun ----
+ function loadAutorun() {
+ listSorted("js/autorun", /\.js$/).forEach(function (f) { execFile("js/autorun/" + f); });
+ listSorted("js/autorun/" + realmDir, /\.js$/).forEach(function (f) { execFile("js/autorun/" + realmDir + "/" + f); });
+ }
+
+ // ---- gamemodes ----
+ function readManifest(mode) {
+ var p = "js/gamemodes/" + mode + "/manifest.json";
+ if (!OV.fileExists(p)) return null;
+ try { return JSON.parse(OV.readFile(p)); } catch (e) { warn("bad manifest for " + mode); return null; }
+ }
+
+ function loadGamemodeFiles(mode) {
+ var dir = "js/gamemodes/" + mode;
+ var sharedPath = dir + "/shared.js";
+ var entryPath = dir + "/" + (isServer ? "server.js" : "client.js");
+ if (OV.fileExists(sharedPath)) execFile(sharedPath);
+ if (OV.fileExists(entryPath)) execFile(entryPath);
+ }
+
+ // Load a gamemode chain: recurse into the base first (GMod DeriveGamemode).
+ function loadGamemodeChain(mode, seen) {
+ seen = seen || {};
+ if (seen[mode]) { warn("gamemode base cycle at '" + mode + "'"); return; }
+ seen[mode] = true;
+ var manifest = readManifest(mode);
+ var baseName = manifest && manifest.base && manifest.base !== mode ? manifest.base : (mode !== "base" ? "base" : null);
+ if (baseName) loadGamemodeChain(baseName, seen);
+ loadGamemodeFiles(mode);
+ }
+
+ // ---- scripted entities ----
+ function loadEntities(root) {
+ root = root || "js/entities";
+ var names = listSorted(root, /./);
+ names.forEach(function (name) {
+ if (name.charAt(0) === ".") return;
+ var single = root + "/" + name;
+ if (/\.js$/.test(name)) {
+ // single-file SENT: runs in both realms; sets globalThis.ENT
+ runEntityFile(name.replace(/\.js$/, ""), [single]);
+ return;
+ }
+ // folder form: shared.js always; init.js server; cl_init.js client
+ var files = [];
+ if (OV.fileExists(single + "/shared.js")) files.push(single + "/shared.js");
+ var entry = single + "/" + (isServer ? "init.js" : "cl_init.js");
+ if (OV.fileExists(entry)) files.push(entry);
+ if (files.length) runEntityFile(name, files);
+ });
+ }
+
+ function runEntityFile(className, files) {
+ if (!globalThis.scripted_ents) return;
+ var prevENT = globalThis.ENT;
+ globalThis.ENT = { Type: "anim", Base: "base_anim", ClassName: className };
+ try {
+ files.forEach(function (f) { execFile(f); });
+ if (globalThis.ENT && typeof globalThis.ENT === "object") {
+ scripted_ents.Register(globalThis.ENT, className);
+ }
+ } finally {
+ globalThis.ENT = prevENT;
+ }
+ }
+
+ // ---- base scripted-entity classes (GMod engine bases) ----
+ function registerBaseEntities() {
+ if (!globalThis.scripted_ents) return;
+ if (!scripted_ents.GetStored("base_entity")) scripted_ents.Register({ Type: "anim" }, "base_entity");
+ if (!scripted_ents.GetStored("base_anim")) scripted_ents.Register({ Type: "anim", Base: "base_entity", AutomaticFrameAdvance: false }, "base_anim");
+ if (!scripted_ents.GetStored("base_point")) scripted_ents.Register({ Type: "point", Base: "base_entity" }, "base_point");
+ if (!scripted_ents.GetStored("base_brush")) scripted_ents.Register({ Type: "brush", Base: "base_entity" }, "base_brush");
+ if (!scripted_ents.GetStored("base_gmodentity")) {
+ scripted_ents.Register({
+ Type: "anim", Base: "base_anim",
+ SetPlayer: function (ply) { this.SetNWEntity("Founder", ply); },
+ GetPlayer: function () { return this.GetNWEntity("Founder", globalThis.NULL); }
+ }, "base_gmodentity");
+ }
+ }
+
+ var OVLoader = {
+ __openvibe: true,
+
+ // Full realm load. opts: { mode, gamemodes: false to skip gamemode files }
+ loadAll: function (opts) {
+ opts = opts || {};
+ var mode = opts.mode || (OV && OV.getMode ? OV.getMode() : "base");
+ registerBaseEntities();
+ loadAutorun();
+ if (opts.gamemodes !== false) loadGamemodeChain(mode);
+ loadEntities();
+ if (globalThis.Addon && Addon.loadAll) Addon.loadAll();
+ log("realm load complete (mode=" + mode + ", realm=" + realmDir + ")");
+ return true;
+ },
+
+ // js_run / js_run_cl
+ runString: function (code, id) {
+ try {
+ var result = (0, eval)(String(code) + "\n//# sourceURL=" + (id || "js_run"));
+ return { ok: true, result: result };
+ } catch (e) {
+ var msg = e && e.stack ? e.stack : String(e);
+ if (OV && OV.error) OV.error("js_run: " + msg);
+ return { ok: false, error: msg };
+ }
+ },
+
+ // js_openscript / js_openscript_cl — path relative to js/ (GMod: lua/)
+ openScript: function (path) {
+ var resolved = globalThis.file ? file._resolve(path) : ("js/" + path);
+ if (!resolved || !OV.fileExists(resolved)) {
+ warn("js_openscript: no such script '" + path + "'");
+ return false;
+ }
+ log("running script " + resolved);
+ return execFile(resolved, true); // force: openscript always re-runs
+ },
+
+ reloadFile: function (path) {
+ var resolved = globalThis.file ? file._resolve(path) : null;
+ if (resolved) delete loadedFiles[resolved];
+ return resolved ? execFile(resolved, true) : false;
+ },
+
+ loadedFiles: loadedFiles
+ };
+
+ globalThis.OVLoader = OVLoader;
+ if (OV && OV.log) OV.log("loader ready (realm=" + realmDir + ")");
+})();
@@ -1,26 +1,29 @@
@@ -1,26 +1,29 @@
-// OpenVibe net library — GMod-style networking for the JS platform.
+// OpenVibe net library — GMod net semantics for the JS platform.
// https://wiki.facepunch.com/gmod/Net_Library_Usage
// https://wiki.facepunch.com/gmod/Net_Library_Usage
//
//
-// Messages are composed with net.Start(name) + net.Write*(...) and sent with
-// net.Send/Broadcast (server->client) or net.SendToServer (client->server).
-// Receivers register net.Receive(name, fn) and read fields in the same order
-// they were written.
+// Faithful behaviors:
+// - util.AddNetworkString pooling required server-side before net.Start
+// - one implicit write buffer; Start resets it; Abort discards
+// - receiver names are case-insensitive; ONE handler per name
+// - server receivers get (len, ply) with the authoritative sender
+// - 64KB payload cap; read-past-end returns type defaults
+// - per-player per-message rate limiting (net.SetRateLimit)
//
//
-// Transport: fields are collected as a typed list, serialized to JSON, and
-// base64-encoded so the payload is a single transport-safe token. The C++
-// bridge carries (name, payloadB64):
-// server->client : OV.netEmit(targets, name, payloadB64) -> usermessage
-// client->server : OV.netSendToServer(name, payloadB64) -> forwarded cmd
-// Inbound messages arrive as the "OVNetReceive" hook fired from C++.
-//
-// NOTE: The client realm has no JS runtime yet, so net.Receive on the client
-// is inert until the client JS runtime lands. Server-side receive (from
-// client->server) and server-side send are fully live today.
+// Transport: fields are collected as a typed list, serialized to JSON and
+// base64-encoded. The C++ bridge carries (name, payloadB64):
+// server->client : OV.netEmit(targetsCsv, name, payloadB64) -> "OVNet" usermessage
+// client->server : OV.netSendToServer(name, payloadB64) -> "ov_net" command
+// Source usermessages cap at ~255 bytes, so payloads are chunked over the
+// reserved "__ovchunk" message and reassembled before dispatch.
+// Inbound messages arrive as the "OVNetReceive" hook fired from the bridge.
(function () {
(function () {
if (globalThis.net && globalThis.net.__openvibe) return;
if (globalThis.net && globalThis.net.__openvibe) return;
var OV = globalThis.OV;
var OV = globalThis.OV;
- var isServer = OV && OV.isServer ? OV.isServer() : true;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+
+ var MAX_PAYLOAD_BYTES = 65533; // GMod per-message cap
+ var CHUNK_SIZE = 180; // base64 chars per usermessage-safe chunk
// --- base64 over UTF-8 (QuickJS has no btoa/atob) ---
// --- base64 over UTF-8 (QuickJS has no btoa/atob) ---
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
@@ -57,7 +60,7 @@
@@ -57,7 +60,7 @@
}
}
function b64decode(str) {
function b64decode(str) {
var bytes = [], i = 0;
var bytes = [], i = 0;
- str = str.replace(/=+$/, "");
+ str = String(str).replace(/=+$/, "");
while (i < str.length) {
while (i < str.length) {
var e0 = B64.indexOf(str[i++]), e1 = B64.indexOf(str[i++]);
var e0 = B64.indexOf(str[i++]), e1 = B64.indexOf(str[i++]);
var e2 = i < str.length ? B64.indexOf(str[i++]) : -1;
var e2 = i < str.length ? B64.indexOf(str[i++]) : -1;
@@ -69,118 +72,283 @@
@@ -69,118 +72,283 @@
return utf8Decode(bytes);
return utf8Decode(bytes);
}
}
- var networkStrings = Object.create(null); // parity with util.AddNetworkString
- var receivers = Object.create(null); // name -> fn(len, ply)
+ function warn(m) { if (OV && OV.warn) OV.warn("net: " + m); }
+ function error(m) { if (OV && OV.error) OV.error("net: " + m); }
+
+ var receivers = Object.create(null); // lowercase name -> fn(len, ply)
+ var writeBuf = null; // { name, fields: [{t,v}], unreliable }
+ var readBuf = null; // { fields, cursor }
+
+ // --- rate limiting (server inbound) ---
+ var rateLimits = Object.create(null); // lowercase name -> per-second cap
+ var DEFAULT_RATE = 30;
+ var rateState = Object.create(null); // "name:userId" -> {sec, count}
+ function rateOk(name, ply) {
+ if (!isServer || !ply || typeof ply.userId !== "function") return true;
+ var cap = rateLimits[name] !== undefined ? rateLimits[name] : DEFAULT_RATE;
+ if (cap <= 0) return true;
+ var now = Math.floor((OV && OV.time ? OV.time() : 0));
+ var key = name + ":" + ply.userId();
+ var st = rateState[key];
+ if (!st || st.sec !== now) { rateState[key] = { sec: now, count: 1 }; return true; }
+ st.count++;
+ return st.count <= cap;
+ }
- var writeBuf = null; // { name, fields: [{t,v}] }
- var readBuf = null; // { fields, cursor }
+ function pooled(name) {
+ return globalThis.util && util.NetworkStringToID ? util.NetworkStringToID(name) !== 0 : true;
+ }
function push(t, v) {
function push(t, v) {
- if (!writeBuf) { OV.warn("net.Write* called without net.Start"); return; }
+ if (!writeBuf) { warn("net.Write* called without net.Start"); return; }
writeBuf.fields.push({ t: t, v: v });
writeBuf.fields.push({ t: t, v: v });
}
}
- function readNext(expectType) {
+ function readNext() {
if (!readBuf || readBuf.cursor >= readBuf.fields.length) return undefined;
if (!readBuf || readBuf.cursor >= readBuf.fields.length) return undefined;
var f = readBuf.fields[readBuf.cursor++];
var f = readBuf.fields[readBuf.cursor++];
return f ? f.v : undefined;
return f ? f.v : undefined;
}
}
function serialize() {
function serialize() {
- return b64encode(JSON.stringify({ n: writeBuf.name, f: writeBuf.fields }));
+ var payload = b64encode(JSON.stringify({ n: writeBuf.name, f: writeBuf.fields, u: writeBuf.unreliable ? 1 : 0 }));
+ if (payload.length > MAX_PAYLOAD_BYTES) {
+ error("message '" + writeBuf.name + "' exceeds 64KB payload cap (" + payload.length + " bytes) — dropped");
+ return null;
+ }
+ return payload;
+ }
+
+ // --- chunked outbound (server->client path over 255-byte usermessages) ---
+ var chunkSeq = 0;
+ function emitChunked(idsCsv, name, payload) {
+ if (!OV || !OV.netEmit) { warn("no netEmit bridge"); return; }
+ if (payload.length <= CHUNK_SIZE) { OV.netEmit(idsCsv, name, payload); return; }
+ var msgId = String(++chunkSeq);
+ var total = Math.ceil(payload.length / CHUNK_SIZE);
+ for (var i = 0; i < total; i++) {
+ var part = payload.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
+ // header "msgId:seq:total:name|" + data — single transport-safe token
+ OV.netEmit(idsCsv, "__ovchunk", b64encode(msgId + ":" + i + ":" + total + ":" + name) + "|" + part);
+ }
+ }
+ function sendToServerChunked(name, payload) {
+ if (!OV || !OV.netSendToServer) { warn("net.SendToServer unavailable (no client bridge)"); return; }
+ if (payload.length <= CHUNK_SIZE) { OV.netSendToServer(name, payload); return; }
+ var msgId = String(++chunkSeq);
+ var total = Math.ceil(payload.length / CHUNK_SIZE);
+ for (var i = 0; i < total; i++) {
+ var part = payload.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
+ OV.netSendToServer("__ovchunk", b64encode(msgId + ":" + i + ":" + total + ":" + name) + "|" + part);
+ }
+ }
+
+ // --- chunk reassembly (both realms) ---
+ var assembling = Object.create(null); // "senderKey:msgId" -> {total, parts[], got, name}
+ function onChunk(payloadB64, ply) {
+ var bar = payloadB64.indexOf("|");
+ if (bar < 0) return null;
+ var header;
+ try { header = b64decode(payloadB64.slice(0, bar)); } catch { return null; }
+ var m = /^([^:]+):(\d+):(\d+):(.*)$/.exec(header);
+ if (!m) return null;
+ var senderKey = ply && typeof ply.userId === "function" ? String(ply.userId()) : "s";
+ var key = senderKey + ":" + m[1];
+ var seq = m[2] | 0, total = m[3] | 0, name = m[4];
+ if (total < 1 || total > 4096 || seq >= total) return null;
+ var st = assembling[key];
+ if (!st) st = assembling[key] = { total: total, parts: [], got: 0, name: name };
+ if (st.parts[seq] === undefined) { st.parts[seq] = payloadB64.slice(bar + 1); st.got++; }
+ if (st.got < st.total) return null;
+ delete assembling[key];
+ return { name: st.name, payload: st.parts.join("") };
+ }
+
+ function entIndexOf(v) {
+ if (v == null) return 0;
+ if (typeof v === "number") return v | 0;
+ if (typeof v.EntIndex === "function") return v.EntIndex() | 0;
+ if (typeof v.entIndex === "function") return v.entIndex() | 0;
+ return 0;
+ }
+ function userIdOf(v) {
+ if (v == null) return -1;
+ if (typeof v === "number") return v | 0;
+ if (typeof v.UserID === "function") return v.UserID() | 0;
+ if (typeof v.userId === "function") return v.userId() | 0;
+ return -1;
+ }
+ function resolveEntity(idx) {
+ idx = idx | 0;
+ if (globalThis.ents && ents.GetByIndex) return ents.GetByIndex(idx);
+ if (OV && OV.playerByUserId) return OV.playerByUserId(idx); // legacy fallback
+ return null;
+ }
+ function resolvePlayer(uid) {
+ uid = uid | 0;
+ if (globalThis.player && player.GetByUserID) return player.GetByUserID(uid);
+ if (OV && OV.playerByUserId) return OV.playerByUserId(uid);
+ return null;
}
}
var net = {
var net = {
__openvibe: true,
__openvibe: true,
Start: function (name, unreliable) {
Start: function (name, unreliable) {
- writeBuf = { name: String(name), fields: [], unreliable: !!unreliable };
- return net;
+ name = String(name);
+ if (isServer && !pooled(name)) {
+ error("net.Start('" + name + "') — name not pooled; call util.AddNetworkString first");
+ writeBuf = null;
+ return false;
+ }
+ writeBuf = { name: name, fields: [], unreliable: !!unreliable };
+ return true;
},
},
- // Writers (bits arg accepted for GMod parity; JSON keeps full precision).
+ Abort: function () { writeBuf = null; },
+
+ // ---- writers (bit widths accepted for GMod parity; JSON keeps precision) ----
+ WriteBit: function (v) { push("b", !!v); return net; },
+ WriteBool: function (v) { push("b", !!v); return net; },
WriteInt: function (v, bits) { push("i", v | 0); return net; },
WriteInt: function (v, bits) { push("i", v | 0); return net; },
WriteUInt: function (v, bits) { push("u", v >>> 0); return net; },
WriteUInt: function (v, bits) { push("u", v >>> 0); return net; },
+ WriteUInt64: function (v) { push("u64", String(v)); return net; },
WriteFloat: function (v) { push("f", Number(v)); return net; },
WriteFloat: function (v) { push("f", Number(v)); return net; },
WriteDouble: function (v) { push("f", Number(v)); return net; },
WriteDouble: function (v) { push("f", Number(v)); return net; },
- WriteBool: function (v) { push("b", !!v); return net; },
WriteString: function (v) { push("s", String(v)); return net; },
WriteString: function (v) { push("s", String(v)); return net; },
- WriteEntity: function (v) { push("e", v && v.entIndex ? v.entIndex() : (v | 0)); return net; },
- WriteTable: function (v) { push("t", v); return net; },
- WriteVector: function (v) { push("v", v); return net; },
-
- // Readers (call in the same order as written).
- ReadInt: function () { return readNext("i") | 0; },
- ReadUInt: function () { return readNext("u") >>> 0; },
- ReadFloat: function () { return Number(readNext("f")); },
- ReadDouble: function () { return Number(readNext("f")); },
- ReadBool: function () { return !!readNext("b"); },
- ReadString: function () { var v = readNext("s"); return v === undefined ? "" : String(v); },
- ReadEntity: function () { var id = readNext("e") | 0; return OV.playerByUserId ? OV.playerByUserId(id) : id; },
- ReadTable: function () { return readNext("t"); },
- ReadVector: function () { return readNext("v"); },
+ WriteData: function (v, len) { var s = String(v); push("d", len === undefined ? s : s.slice(0, len)); return net; },
+ WriteEntity: function (v) { push("e", entIndexOf(v)); return net; },
+ WritePlayer: function (v) { push("p", userIdOf(v)); return net; },
+ WriteVector: function (v) { push("v", v ? { x: +v.x || 0, y: +v.y || 0, z: +v.z || 0 } : { x: 0, y: 0, z: 0 }); return net; },
+ WriteNormal: function (v) { return net.WriteVector(v); },
+ WriteAngle: function (v) { push("a", v ? { p: +v.p || +v.pitch || 0, y: +v.y || +v.yaw || 0, r: +v.r || +v.roll || 0 } : { p: 0, y: 0, r: 0 }); return net; },
+ WriteColor: function (v, writeAlpha) {
+ var c = v || {};
+ var out = { r: c.r | 0, g: c.g | 0, b: c.b | 0 };
+ if (writeAlpha === undefined || writeAlpha) out.a = c.a === undefined ? 255 : c.a | 0;
+ push("c", out); return net;
+ },
+ WriteTable: function (v, sequential) { push("t", v); return net; },
+ WriteType: function (v) { push("y", v); return net; },
+
+ // ---- readers (same order as written; past-end -> type default) ----
+ ReadBit: function () { return readNext() ? 1 : 0; },
+ ReadBool: function () { return !!readNext(); },
+ ReadInt: function (bits) { return readNext() | 0; },
+ ReadUInt: function (bits) { return readNext() >>> 0; },
+ ReadUInt64: function () { var v = readNext(); return v === undefined ? "0" : String(v); },
+ ReadFloat: function () { var v = readNext(); return v === undefined ? 0 : Number(v); },
+ ReadDouble: function () { var v = readNext(); return v === undefined ? 0 : Number(v); },
+ ReadString: function () { var v = readNext(); return v === undefined ? "" : String(v); },
+ ReadData: function (len) { var v = readNext(); v = v === undefined ? "" : String(v); return len === undefined ? v : v.slice(0, len); },
+ ReadEntity: function () { return resolveEntity(readNext() | 0); },
+ ReadPlayer: function () { return resolvePlayer(readNext() | 0); },
+ ReadVector: function () { var v = readNext(); return v && typeof v === "object" ? v : { x: 0, y: 0, z: 0 }; },
+ ReadNormal: function () { return net.ReadVector(); },
+ ReadAngle: function () { var v = readNext(); return v && typeof v === "object" ? v : { p: 0, y: 0, r: 0 }; },
+ ReadColor: function (hasAlpha) {
+ var v = readNext();
+ var c = v && typeof v === "object" ? v : { r: 0, g: 0, b: 0 };
+ if (c.a === undefined) c.a = 255;
+ return c;
+ },
+ ReadTable: function (sequential) { var v = readNext(); return v === undefined ? {} : v; },
+ ReadType: function () { return readNext(); },
+
+ BytesWritten: function () { return writeBuf ? JSON.stringify(writeBuf.fields).length + 3 : 0; },
+ BytesLeft: function () {
+ if (!readBuf) return 0;
+ var rest = readBuf.fields.slice(readBuf.cursor);
+ return rest.length ? JSON.stringify(rest).length : 0;
+ },
Receive: function (name, fn) {
Receive: function (name, fn) {
if (typeof fn !== "function") throw new Error("net.Receive requires a function");
if (typeof fn !== "function") throw new Error("net.Receive requires a function");
- receivers[String(name)] = fn;
+ receivers[String(name).toLowerCase()] = fn;
},
},
- // server -> client. ids are passed to C++ as a comma-separated list of
- // player userIds; -1 means "broadcast to everyone".
+ SetRateLimit: function (name, perSecond) { rateLimits[String(name).toLowerCase()] = perSecond | 0; },
+
+ // ---- send: server -> client ----
Send: function (target) {
Send: function (target) {
if (!writeBuf) return;
if (!writeBuf) return;
- if (!isServer) { OV.warn("net.Send is server-only; use SendToServer on the client"); return; }
+ if (!isServer) { warn("net.Send is server-only; use SendToServer on the client"); writeBuf = null; return; }
var ids = [];
var ids = [];
if (target == null) { ids = [-1]; }
if (target == null) { ids = [-1]; }
- else if (Array.isArray(target)) { ids = target.map(function (p) { return p && p.userId ? p.userId() : (p | 0); }); }
- else { ids = [target && target.userId ? target.userId() : (target | 0)]; }
+ else if (Array.isArray(target)) { ids = target.map(userIdOf); }
+ else { ids = [userIdOf(target)]; }
var name = writeBuf.name, payload = serialize();
var name = writeBuf.name, payload = serialize();
- if (OV.netEmit) OV.netEmit(ids.join(","), name, payload);
writeBuf = null;
writeBuf = null;
+ if (payload != null) emitChunked(ids.join(","), name, payload);
},
},
Broadcast: function () {
Broadcast: function () {
if (!writeBuf) return;
if (!writeBuf) return;
- if (!isServer) { OV.warn("net.Broadcast is server-only"); return; }
+ if (!isServer) { warn("net.Broadcast is server-only"); writeBuf = null; return; }
var name = writeBuf.name, payload = serialize();
var name = writeBuf.name, payload = serialize();
- if (OV.netEmit) OV.netEmit("-1", name, payload);
writeBuf = null;
writeBuf = null;
+ if (payload != null) emitChunked("-1", name, payload);
},
},
+ SendOmit: function (omit) {
+ if (!writeBuf) return;
+ if (!isServer) { warn("net.SendOmit is server-only"); writeBuf = null; return; }
+ var omitIds = {};
+ (Array.isArray(omit) ? omit : [omit]).forEach(function (p) { omitIds[userIdOf(p)] = true; });
+ var all = (OV && OV.players ? OV.players() : []) || [];
+ var ids = [];
+ for (var i = 0; i < all.length; i++) {
+ var uid = userIdOf(all[i]);
+ if (!omitIds[uid]) ids.push(uid);
+ }
+ var name = writeBuf.name, payload = serialize();
+ writeBuf = null;
+ if (payload != null && ids.length) emitChunked(ids.join(","), name, payload);
+ },
+ // PVS/PAS need engine visibility data; until the native bridge exposes it
+ // these behave as Broadcast (documented deviation).
+ SendPVS: function (pos) { return net.Broadcast(); },
+ SendPAS: function (pos) { return net.Broadcast(); },
- // client -> server
+ // ---- send: client -> server ----
SendToServer: function () {
SendToServer: function () {
if (!writeBuf) return;
if (!writeBuf) return;
- var payload = serialize();
- if (OV.netSendToServer) OV.netSendToServer(writeBuf.name, payload);
- else OV.warn("net.SendToServer unavailable (no client bridge)");
+ if (isServer) { warn("net.SendToServer is client-only"); writeBuf = null; return; }
+ var name = writeBuf.name, payload = serialize();
writeBuf = null;
writeBuf = null;
+ if (payload != null) sendToServerChunked(name, payload);
},
},
- // Called from the C++ "OVNetReceive" hook. ply is the sending player object
- // for client->server messages, or null (server->client / server console).
+ // ---- inbound dispatch (fired via the OVNetReceive hook) ----
_dispatch: function (name, payloadB64, ply) {
_dispatch: function (name, payloadB64, ply) {
- var fn = receivers[String(name)];
+ name = String(name);
+ if (name === "__ovchunk") {
+ var whole = onChunk(String(payloadB64), ply);
+ if (!whole) return;
+ name = whole.name;
+ payloadB64 = whole.payload;
+ }
+ var key = name.toLowerCase();
+ var fn = receivers[key];
if (!fn) return;
if (!fn) return;
+ if (!rateOk(key, ply)) { warn("rate limit exceeded for '" + name + "'"); return; }
var decoded;
var decoded;
try { decoded = JSON.parse(b64decode(payloadB64)); }
try { decoded = JSON.parse(b64decode(payloadB64)); }
- catch (e) { OV.error("net: bad payload for " + name + ": " + (e && e.message)); return; }
+ catch (e) { error("bad payload for " + name + ": " + (e && e.message)); return; }
readBuf = { fields: (decoded && decoded.f) || [], cursor: 0 };
readBuf = { fields: (decoded && decoded.f) || [], cursor: 0 };
- try { fn(readBuf.fields.length, ply || null); }
- catch (e) { OV.error("net.Receive('" + name + "') threw: " + (e && e.message)); }
+ var lenBits = String(payloadB64).length * 8;
+ try { fn(lenBits, isServer ? (ply || null) : null); }
+ catch (e) { error("net.Receive('" + name + "') threw: " + (e && e.stack ? e.stack : e.message)); }
readBuf = null;
readBuf = null;
- }
+ },
+
+ Receivers: receivers
};
};
globalThis.net = net;
globalThis.net = net;
- // util.AddNetworkString parity (names also travel inline, so this is advisory).
- globalThis.util = globalThis.util || {};
- if (!globalThis.util.AddNetworkString) {
- globalThis.util.AddNetworkString = function (name) { networkStrings[String(name)] = true; return name; };
- }
-
- // Bridge C++ inbound -> JS. C++ fires "OVNetReceive" with (name, payloadB64, plyUserId).
+ // Bridge inbound -> JS. The bridge fires "OVNetReceive" with (name, payloadB64, ply).
if (globalThis.hook && typeof hook.Add === "function") {
if (globalThis.hook && typeof hook.Add === "function") {
hook.Add("OVNetReceive", "OpenVibeNetDispatch", function (name, payloadB64, ply) {
hook.Add("OVNetReceive", "OpenVibeNetDispatch", function (name, payloadB64, ply) {
net._dispatch(name, payloadB64, ply);
net._dispatch(name, payloadB64, ply);
+ return undefined;
});
});
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,221 @@
@@ -0,0 +1,221 @@
+// OpenVibe Player class — GMod Player semantics over the native player bridge.
+// https://wiki.facepunch.com/gmod/Player
+//
+// Player extends Entity. Native player handles (from the C++ bridge or the
+// Node runtime) are wrapped once via Player.fromNative and cached by UserID;
+// gamemode.call wraps hook arguments automatically, so gamemode code always
+// sees framework Player objects. The legacy lowercase API (ply.chat(),
+// ply.setTeam(), ...) is kept for existing gamemode code.
+(function () {
+ if (globalThis.Player && globalThis.Player.__openvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+ var Entity = globalThis.Entity;
+
+ var byUserId = Object.create(null); // userId -> Player
+
+ function Player(userId, entIndex) {
+ Entity.call(this, entIndex || (100000 + userId));
+ this.__ovPlayer = true;
+ this._userId = userId | 0;
+ this._native = null; // native bridge handle (lowercase methods)
+ this._r.class = "player";
+ this._r.health = 100;
+ this._r.maxHealth = 100;
+ this._r.team = 0;
+ this._r.frags = 0;
+ this._r.deaths = 0;
+ this._r.alive = true;
+ this._r.spawned = true;
+ this._r.playerName = "";
+ this._r.steamId = "";
+ }
+ Player.__openvibe = true;
+ Player.prototype = Object.create(Entity.prototype);
+ Player.prototype.constructor = Player;
+ var P = Player.prototype;
+
+ function nat(self, method, args) {
+ var h = self._native;
+ if (h && typeof h[method] === "function") {
+ try { return h[method].apply(h, args || []); } catch (e) { OV && OV.warn && OV.warn("player." + method + ": " + (e && e.message)); }
+ }
+ return undefined;
+ }
+
+ // ---- identity ----
+ P.IsPlayer = function () { return true; };
+ P.UserID = function () { return this._userId; };
+ P.Nick = function () { var v = nat(this, "name"); return v !== undefined ? v : (this._r.playerName || "Player" + this._userId); };
+ P.Name = P.Nick;
+ P.SteamID = function () { var v = nat(this, "steamId"); return v !== undefined ? v : this._r.steamId; };
+ P.EntIndex = function () { var v = nat(this, "entIndex"); return v !== undefined ? v : this._key; };
+ P.entIndex = P.EntIndex;
+ P.IsBot = function () { return /^BOT/.test(this.SteamID() || ""); };
+ P.IsValid = function () { return !this._r.removed && byUserId[this._userId] === this; };
+ P.isValid = P.IsValid;
+
+ // ---- team / score ----
+ P.Team = function () { var v = nat(this, "team"); return v !== undefined ? v : this._r.team; };
+ P.SetTeam = function (t) { this._r.team = t | 0; nat(this, "setTeam", [t | 0]); };
+ P.Frags = function () { return this._r.frags; };
+ P.AddFrags = function (n) { this._r.frags += n | 0; };
+ P.Deaths = function () { return this._r.deaths; };
+ P.AddDeaths = function (n) { this._r.deaths += n | 0; };
+
+ // ---- life ----
+ P.Health = function () { var v = nat(this, "health"); return v !== undefined ? v : this._r.health; };
+ P.SetHealth = function (v) { this._r.health = v | 0; nat(this, "setHealth", [v | 0]); };
+ P.Alive = function () { return this._r.alive && this.Health() > 0; };
+ P.Kill = function () {
+ if (!isServer) return;
+ if (nat(this, "kill") === undefined) {
+ // no native kill — emulate for the logical backend
+ this._r.alive = false;
+ this._r.health = 0;
+ if (globalThis.hook) { try { hook.Run("PlayerDeath", this, globalThis.NULL, globalThis.NULL); } catch {} }
+ }
+ };
+ P.KillSilent = P.Kill;
+ P.Spawn = function () {
+ this._r.alive = true;
+ if (this._r.health <= 0) this._r.health = 100;
+ nat(this, "respawn");
+ };
+ P.Armor = function () { return this._r.armor | 0; };
+ P.SetArmor = function (v) { this._r.armor = v | 0; nat(this, "setArmor", [v | 0]); };
+
+ // ---- movement / state ----
+ P.Freeze = function (b) { this._r.frozen = !!b; nat(this, "freeze", [!!b]); };
+ P.IsFrozen = function () { return !!this._r.frozen; };
+ P.GetPos = function () { var v = nat(this, "getPos"); return v || Entity.prototype.GetPos.call(this); };
+ P.SetPos = function (v) { Entity.prototype.SetPos.call(this, v); nat(this, "setPos", [v.x, v.y, v.z]); };
+
+ // ---- chat / console ----
+ P.ChatPrint = function (msg) { nat(this, "chat", [String(msg)]); };
+ P.PrintMessage = function (type, msg) { this.ChatPrint(msg); };
+ P.ConCommand = function (cmd) { nat(this, "runCommand", [String(cmd)]); };
+ P.SendNet = function (name, fn) { /* convenience: net.Start(name); fn(); net.Send(this) */ };
+
+ // ---- weapons (native support pending; degrade gracefully) ----
+ P.Give = function (cls) { nat(this, "give", [String(cls)]); return globalThis.NULL; };
+ P.StripWeapons = function () { nat(this, "stripWeapons"); };
+
+ // ---- legacy lowercase API (existing gamemodes) ----
+ P.userId = P.UserID;
+ P.steamId = P.SteamID;
+ P.name = P.Nick;
+ P.health = P.Health;
+ P.setHealth = P.SetHealth;
+ P.team = P.Team;
+ P.setTeam = P.SetTeam;
+ P.chat = P.ChatPrint;
+ P.runCommand = P.ConCommand;
+
+ // ---- wrapping / registry ----
+ Player.fromNative = function (h) {
+ if (!h) return null;
+ if (h.__ovPlayer) return h;
+ var uid = -1;
+ try { uid = typeof h.userId === "function" ? h.userId() | 0 : -1; } catch {}
+ if (uid < 0) return h;
+ var ply = byUserId[uid];
+ if (!ply) {
+ var entIndex = 0;
+ try { entIndex = typeof h.entIndex === "function" ? h.entIndex() | 0 : 0; } catch {}
+ ply = new Player(uid, entIndex > 0 ? entIndex : undefined);
+ byUserId[uid] = ply;
+ Entity._register(ply);
+ }
+ ply._native = h;
+ try { if (typeof h.name === "function") ply._r.playerName = h.name(); } catch {}
+ try { if (typeof h.steamId === "function") ply._r.steamId = h.steamId() || ply._r.steamId; } catch {}
+ return ply;
+ };
+
+ Player.getOrCreate = function (userId, name) {
+ var ply = byUserId[userId | 0];
+ if (!ply) {
+ ply = new Player(userId | 0);
+ ply._r.playerName = name || "";
+ byUserId[userId | 0] = ply;
+ Entity._register(ply);
+ }
+ return ply;
+ };
+
+ Player.drop = function (userId) {
+ var ply = byUserId[userId | 0];
+ if (ply) {
+ Entity._unregister(ply);
+ delete byUserId[userId | 0];
+ }
+ };
+
+ var playerLib = {
+ __openvibe: true,
+ GetAll: function () {
+ // Prefer the live native list (covers players the wrapper hasn't seen)
+ if (OV && OV.players) {
+ var out = [];
+ (OV.players() || []).forEach(function (h) {
+ var p = Player.fromNative(h);
+ if (p) out.push(p);
+ });
+ if (out.length) return out;
+ }
+ var cached = [];
+ for (var k in byUserId) cached.push(byUserId[k]);
+ return cached;
+ },
+ GetByUserID: function (uid) {
+ var p = byUserId[uid | 0];
+ if (p) return p;
+ if (OV && OV.playerByUserId) {
+ var h = OV.playerByUserId(uid | 0);
+ if (h) return Player.fromNative(h);
+ }
+ return null;
+ },
+ GetBySteamID: function (sid) {
+ sid = String(sid);
+ return playerLib.GetAll().find(function (p) { return p.SteamID() === sid; }) || null;
+ },
+ GetCount: function () { return playerLib.GetAll().length; },
+ Iterator: function () {
+ var all = playerLib.GetAll(), i = 0;
+ return function () { return i < all.length ? [i, all[i++]] : null; };
+ }
+ };
+
+ // ---- client realm: LocalPlayer ----
+ var localPlayer = null;
+ globalThis.LocalPlayer = function () {
+ if (isServer) return globalThis.NULL;
+ if (!localPlayer && OV && OV.localPlayer) {
+ var h = OV.localPlayer();
+ if (h) localPlayer = Player.fromNative(h);
+ }
+ return localPlayer || globalThis.NULL;
+ };
+ Player._setLocal = function (p) { localPlayer = p; };
+
+ // Cleanup on disconnect (server realm).
+ if (globalThis.hook && typeof hook.Add === "function" && isServer) {
+ hook.Add("PlayerDisconnected", "OpenVibePlayerRegistry", function (ply) {
+ if (ply && typeof ply.UserID === "function") {
+ // Defer drop so later hooks this tick still see the player.
+ var uid = ply.UserID();
+ if (globalThis.timer && timer.simple) timer.simple(0, function () { Player.drop(uid); });
+ else Player.drop(uid);
+ }
+ return undefined;
+ });
+ }
+
+ globalThis.Player = Player;
+ globalThis.player = playerLib;
+
+ if (OV && OV.log) OV.log("player library ready");
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+// OpenVibe realm globals — GMod States parity (SERVER / CLIENT).
+// https://wiki.facepunch.com/gmod/States
+//
+// The menu realm (MENU) lives in the HTML GUI, not in this runtime.
+(function () {
+ var OV = globalThis.OV;
+ var isServer = OV && typeof OV.isServer === "function" ? !!OV.isServer() : true;
+
+ globalThis.SERVER = isServer;
+ globalThis.CLIENT = !isServer;
+ globalThis.MENU = false;
+
+ // GMod-style validity check that also handles null/undefined and objects
+ // exposing IsValid()/isValid().
+ globalThis.IsValid = function (obj) {
+ if (obj == null) return false;
+ if (typeof obj.IsValid === "function") return !!obj.IsValid();
+ if (typeof obj.isValid === "function") return !!obj.isValid();
+ return true;
+ };
+
+ // CurTime parity — engine time via the bridge.
+ if (!globalThis.CurTime) {
+ globalThis.CurTime = function () { return OV && OV.time ? OV.time() : 0; };
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+// OpenVibe team library — GMod team semantics.
+// https://wiki.facepunch.com/gmod/team
+(function () {
+ if (globalThis.team && globalThis.team.__openvibe) return;
+
+ var teams = Object.create(null); // id -> {name, color, score, joinable}
+
+ function players() {
+ return globalThis.player && player.GetAll ? player.GetAll() : (globalThis.OV && OV.players ? OV.players() : []);
+ }
+ function teamOf(p) {
+ if (!p) return 0;
+ if (typeof p.Team === "function") return p.Team() | 0;
+ if (typeof p.team === "function") return p.team() | 0;
+ return 0;
+ }
+
+ globalThis.team = {
+ __openvibe: true,
+
+ SetUp: function (id, name, color, joinable) {
+ id = id | 0;
+ teams[id] = {
+ name: String(name),
+ color: color || (globalThis.Color ? Color(255, 255, 255) : { r: 255, g: 255, b: 255, a: 255 }),
+ score: teams[id] ? teams[id].score : 0,
+ joinable: joinable !== false
+ };
+ return id;
+ },
+ Valid: function (id) { return !!teams[id | 0]; },
+ GetName: function (id) { var t = teams[id | 0]; return t ? t.name : "Unassigned"; },
+ GetColor: function (id) { var t = teams[id | 0]; return t ? t.color : { r: 255, g: 255, b: 255, a: 255 }; },
+ GetScore: function (id) { var t = teams[id | 0]; return t ? t.score : 0; },
+ SetScore: function (id, score) { var t = teams[id | 0]; if (t) t.score = score | 0; },
+ AddScore: function (id, amount) { var t = teams[id | 0]; if (t) t.score += amount | 0; },
+ GetPlayers: function (id) {
+ id = id | 0;
+ return players().filter(function (p) { return teamOf(p) === id; });
+ },
+ NumPlayers: function (id) { return this.GetPlayers(id).length; },
+ GetAllTeams: function () {
+ var out = {};
+ for (var id in teams) out[id] = teams[id];
+ return out;
+ }
+ };
+
+ if (globalThis.OV && OV.log) OV.log("team library ready");
+})();
@@ -66,6 +66,29 @@
@@ -66,6 +66,29 @@
tick,
tick,
list() {
list() {
return Object.keys(timers).sort();
return Object.keys(timers).sort();
+ },
+
+ // GMod-style aliases (https://wiki.facepunch.com/gmod/timer)
+ Create(id, delay, reps, fn) {
+ return create(id, delay, reps, fn);
+ },
+ Simple(delay, fn) {
+ return create(`simple_${nextAutoId++}`, delay, 1, fn);
+ },
+ Remove(id) {
+ return remove(id);
+ },
+ Exists(id) {
+ return !!timers[String(id || "")];
+ },
+ Adjust(id, delay, reps, fn) {
+ const item = timers[String(id || "")];
+ if (!item) return false;
+ if (delay !== undefined && delay !== null) item.delay = Math.max(0, Number(delay) || 0);
+ if (reps !== undefined && reps !== null) item.reps = Number(reps) || 0;
+ if (typeof fn === "function") item.fn = fn;
+ item.next = now() + item.delay;
+ return true;
}
}
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
+// OpenVibe util library — network string pooling + helpers.
+// https://wiki.facepunch.com/gmod/util.AddNetworkString
+(function () {
+ if (globalThis.util && globalThis.util.__openvibe) return;
+
+ var OV = globalThis.OV;
+ var isServer = OV && OV.isServer ? !!OV.isServer() : true;
+
+ // Pooled network strings: name -> id. GMod's table has 4095 usable slots.
+ var MAX_STRINGS = 4095;
+ var names = Object.create(null);
+ var ids = [null]; // id 0 reserved (NULL)
+
+ function addNetworkString(name) {
+ name = String(name);
+ if (names[name] !== undefined) return names[name];
+ if (!isServer) {
+ // Clients cannot pool. GMod silently only lets the server pool; we track
+ // locally so client Start() of a known name still works after sync.
+ }
+ if (ids.length > MAX_STRINGS) {
+ if (OV && OV.warn) OV.warn("util.AddNetworkString: networkstring table is full, can't add " + name);
+ return 0;
+ }
+ var id = ids.length;
+ ids.push(name);
+ names[name] = id;
+ return id;
+ }
+
+ function networkStringToID(name) {
+ var id = names[String(name)];
+ return id === undefined ? 0 : id;
+ }
+
+ function networkIDToString(id) {
+ return ids[id | 0] || undefined;
+ }
+
+ // FNV-1a 32-bit — stable content hash usable in both QuickJS and Node,
+ // used by the file-sync manifest (not cryptographic).
+ function fnv1a(str) {
+ str = String(str);
+ var h = 0x811c9dc5;
+ for (var i = 0; i < str.length; i++) {
+ h ^= str.charCodeAt(i) & 0xff;
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
+ }
+ return ("0000000" + h.toString(16)).slice(-8);
+ }
+
+ var util = globalThis.util || {};
+ util.__openvibe = true;
+ util.AddNetworkString = addNetworkString;
+ util.NetworkStringToID = networkStringToID;
+ util.NetworkIDToString = networkIDToString;
+ util.CRC = fnv1a;
+ util.TableToJSON = function (t, pretty) { return JSON.stringify(t, null, pretty ? 2 : 0); };
+ util.JSONToTable = function (s) { try { return JSON.parse(String(s)); } catch { return null; } };
+ util.IsValid = function (o) { return globalThis.IsValid ? IsValid(o) : o != null; };
+
+ globalThis.util = util;
+
+ // Shared pooled-name access for net.js.
+ util.__netNames = names;
+ util.__netIds = ids;
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+// ov_bouncy_crate — client realm.
+include("entities/ov_bouncy_crate/shared.js");
+
+ENT.Initialize = function () {
+ // Clientside representation; the engine draws the model itself.
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+// ov_bouncy_crate — server realm.
+AddCSJSFile("entities/ov_bouncy_crate/shared.js");
+AddCSJSFile("entities/ov_bouncy_crate/cl_init.js");
+include("entities/ov_bouncy_crate/shared.js");
+
+ENT.Initialize = function () {
+ this.SetModel("models/props_junk/wood_crate001a.mdl");
+ this.PhysicsInit(SOLID_VPHYSICS);
+ this.SetMoveType(MOVETYPE_VPHYSICS);
+ this.SetSolid(SOLID_VPHYSICS);
+ this.SetHealth(50);
+ this.SetMaxHealth(50);
+ this.SetBounces(0);
+ const phys = this.GetPhysicsObject();
+ if (IsValid(phys)) phys.Wake();
+ this.NextThink(CurTime() + 2);
+};
+
+ENT.Think = function () {
+ // Hop every 2 seconds.
+ this.SetBounces(this.GetBounces() + 1);
+ const phys = this.GetPhysicsObject();
+ if (IsValid(phys)) phys.ApplyForceCenter({ x: 0, y: 0, z: 4000 });
+ this.NextThink(CurTime() + 2);
+};
+
+ENT.OnTakeDamage = function (dmg) {
+ this.SetHealth(this.Health() - dmg.GetDamage());
+ if (this.Health() <= 0) this.Remove();
+};
+
+ENT.OnRemove = function () {
+ if (globalThis.OV && OV.log) OV.log("bouncy crate removed after " + this.GetBounces() + " bounces");
+};
+
+ENT.Use = function (activator) {
+ if (activator && activator.ChatPrint) activator.ChatPrint("Crate has bounced " + this.GetBounces() + " times.");
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+// ov_bouncy_crate — demo scripted entity (folder form, GMod SENT layout).
+// shared.js runs in both realms; the loader provides the ENT global.
+ENT.Type = "anim";
+ENT.Base = "base_gmodentity";
+ENT.PrintName = "Bouncy Crate";
+ENT.Author = "OpenVibe.Games";
+ENT.Spawnable = true;
+ENT.Category = "OpenVibe";
+
+ENT.SetupDataTables = function () {
+ this.NetworkVar("Int", 0, "Bounces");
+};
@@ -1 +1,35 @@
@@ -1 +1,35 @@
-console.log("Base client realm loaded");
+// OpenVibe base gamemode — client realm.
+// Receives the HUD state snapshots the server broadcasts and forwards them to
+// the HTML GUI overlay (window.OV.onHudState) through the menu bridge.
+(function () {
+ const GM = {
+ mode: "base",
+ name: "OpenVibe Base (client)",
+
+ Initialize() {
+ OV.log("Base client Initialize");
+ }
+ };
+
+ function pushHudToGui(state) {
+ if (!OV || typeof OV.menuJS !== "function") return;
+ try {
+ const json = JSON.stringify(state).replace(/[\\"']/g, function (c) { return "\\" + c; });
+ // ov_menu_js -> panel RunJavascript. The GUI ignores it when no HUD view.
+ OV.menuJS('window.OV&&OV.onHudState&&OV.onHudState(JSON.parse("' + json + '"))');
+ } catch (e) { /* GUI push is best-effort */ }
+ }
+
+ if (globalThis.net && net.__openvibe) {
+ net.Receive(globalThis.OVBase ? OVBase.HUD_NET : "OV_HudState", function () {
+ const state = net.ReadTable();
+ if (globalThis.OVBase) OVBase.hudState = state;
+ hook.Run("OVHudState", state);
+ pushHudToGui(state);
+ });
+ }
+
+ // Client realm mirrors the base gamemode so submode client files can derive.
+ gamemode.setBase(GM);
+ gamemode.set(GM, { base: true });
+})();
@@ -17,11 +17,20 @@
@@ -17,11 +17,20 @@
// Round tracking (do not override)
// Round tracking (do not override)
_roundState: "idle", // "idle" | "countdown" | "active" | "ended"
_roundState: "idle", // "idle" | "countdown" | "active" | "ended"
_roundNumber: 0,
_roundNumber: 0,
+ _roundEndsAt: 0,
Initialize() {
Initialize() {
OV.log("Base Initialize fired");
OV.log("Base Initialize fired");
},
},
+ // Define teams via team.SetUp here (submodes override).
+ CreateTeams() {
+ if (globalThis.team) {
+ team.SetUp(0, "Unassigned", Color(200, 200, 200));
+ team.SetUp(1, "Spectator", Color(150, 150, 150), false);
+ }
+ },
+
MapInitialize(mapName) {
MapInitialize(mapName) {
OV.log(`Base MapInitialize: ${mapName}`);
OV.log(`Base MapInitialize: ${mapName}`);
this.scheduleRoundStart();
this.scheduleRoundStart();
@@ -29,9 +38,14 @@
@@ -29,9 +38,14 @@
PlayerInitialSpawn(ply) {
PlayerInitialSpawn(ply) {
ply.chat("Welcome to OpenVibe: Source.");
ply.chat("Welcome to OpenVibe: Source.");
+ this.broadcastHudState();
+ },
+
+ PlayerSpawn(ply) {
+ hook.Run("PlayerLoadout", ply);
},
},
- PlayerSpawn(_ply) {},
+ PlayerLoadout(_ply) {},
PlayerDeath(_victim, _attacker) {},
PlayerDeath(_victim, _attacker) {},
@@ -47,9 +61,11 @@
@@ -47,9 +61,11 @@
startRound() {
startRound() {
this._roundNumber += 1;
this._roundNumber += 1;
this._roundState = "active";
this._roundState = "active";
+ this._roundEndsAt = OV.time() + this.roundDuration;
OV.log(`[OV Round] RoundStart round=${this._roundNumber}`);
OV.log(`[OV Round] RoundStart round=${this._roundNumber}`);
hook.Run("RoundStart", this._roundNumber);
hook.Run("RoundStart", this._roundNumber);
OV.broadcast(`Round ${this._roundNumber} — begin!`);
OV.broadcast(`Round ${this._roundNumber} — begin!`);
+ this.broadcastHudState();
const roundDuration = this.roundDuration;
const roundDuration = this.roundDuration;
const roundNumber = this._roundNumber;
const roundNumber = this._roundNumber;
@@ -65,13 +81,54 @@
@@ -65,13 +81,54 @@
endRound(reason) {
endRound(reason) {
if (this._roundState !== "active") return;
if (this._roundState !== "active") return;
this._roundState = "ended";
this._roundState = "ended";
+ this._roundEndsAt = 0;
OV.log(`[OV Round] RoundEnd round=${this._roundNumber} reason=${reason}`);
OV.log(`[OV Round] RoundEnd round=${this._roundNumber} reason=${reason}`);
hook.Run("RoundEnd", this._roundNumber, reason || "time");
hook.Run("RoundEnd", this._roundNumber, reason || "time");
OV.broadcast(`Round ${this._roundNumber} ended (${reason || "time"}).`);
OV.broadcast(`Round ${this._roundNumber} ended (${reason || "time"}).`);
+ this.broadcastHudState();
timer.remove(`ov_round_end_${this._roundNumber}`);
timer.remove(`ov_round_end_${this._roundNumber}`);
this.scheduleRoundStart();
this.scheduleRoundStart();
},
},
+ // ---- HUD state replication (drives the HTML HUD overlay) ----
+ // Broadcasts a compact snapshot over the net library; the client realm
+ // forwards it into the GUI (window.OV.onHudState).
+ buildHudState() {
+ const teams = [];
+ if (globalThis.team && team.GetAllTeams) {
+ const all = team.GetAllTeams();
+ for (const id in all) {
+ teams.push({
+ id: id | 0, name: all[id].name, color: all[id].color,
+ score: all[id].score, players: team.NumPlayers(id | 0)
+ });
+ }
+ }
+ return {
+ mode: this.mode, name: this.name,
+ state: this._roundState, round: this._roundNumber,
+ timeLeft: this._roundEndsAt ? Math.max(0, Math.round(this._roundEndsAt - OV.time())) : 0,
+ players: (OV.players ? OV.players() : []).length,
+ teams
+ };
+ },
+
+ broadcastHudState() {
+ if (!globalThis.net || !net.__openvibe) return;
+ try {
+ net.Start(globalThis.OVBase ? OVBase.HUD_NET : "OV_HudState");
+ net.WriteTable(this.buildHudState());
+ net.Broadcast();
+ } catch (e) { /* net transport not up yet */ }
+ },
+
+ _startHudTicker() {
+ const self = this;
+ if (globalThis.timer && timer.create) {
+ timer.create("ov_hud_state", 3, 0, function () { self.broadcastHudState(); });
+ }
+ },
+
// Begin countdown then start round.
// Begin countdown then start round.
scheduleRoundStart() {
scheduleRoundStart() {
const players = OV.players ? OV.players() : [];
const players = OV.players ? OV.players() : [];
@@ -100,6 +157,17 @@
@@ -100,6 +157,17 @@
gamemode.setBase(GM);
gamemode.setBase(GM);
gamemode.set(GM, { base: true });
gamemode.set(GM, { base: true });
+ // Base setup that must run for EVERY mode, even ones that override
+ // GM.Initialize without calling up the chain (hook.Add'd hooks always run
+ // before the GM method and cannot be shadowed by submodes).
+ hook.Add("Initialize", "OpenVibeBaseSetup", function () {
+ if (globalThis.util && util.AddNetworkString) util.AddNetworkString(globalThis.OVBase ? OVBase.HUD_NET : "OV_HudState");
+ hook.Run("CreateTeams");
+ const gm = gamemode.get();
+ if (gm && typeof gm._startHudTicker === "function") gm._startHudTicker();
+ return undefined;
+ });
+
// Load addons now that all core systems (require, hook, command, timer,
// Load addons now that all core systems (require, hook, command, timer,
// gamemode) are available. Runs in both realms; Addon.loadAll() picks the
// gamemode) are available. Runs in both realms; Addon.loadAll() picks the
// right per-realm entry files via OV.isServer(). The active gamemode file
// right per-realm entry files via OV.isServer(). The active gamemode file
@@ -1,22 +1,13 @@
@@ -1,22 +1,13 @@
-export const GM = {
- mode: "base",
- name: "OpenVibe Base",
-
- Initialize() {
- console.log("Base gamemode initialized");
- },
-
- PlayerInitialSpawn(ply) {
- ply.chat("Welcome to OpenVibe: Source");
- },
-
- PlayerSpawn(_ply) {},
-
- PlayerDeath(_victim, _attacker, _inflictor) {},
-
- PlayerSay(_ply, text) {
- return undefined;
- }
-};
-
-gamemode.set(GM);
+// OpenVibe base gamemode — shared realm (both server and client).
+// Identity + shared constants live here; realm entries build on them.
+(function () {
+ globalThis.OVBase = {
+ TEAM_UNASSIGNED: 0,
+ TEAM_SPECTATOR: 1,
+ // Modes define their own playable teams as 2/3.
+ HUD_NET: "OV_HudState",
+ hudState: null // last known round/HUD snapshot (client keeps it fresh)
+ };
+
+ if (globalThis.OV && OV.log) OV.log("base shared.js loaded (realm=" + (globalThis.SERVER ? "server" : "client") + ")");
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+// OpenVibe Deathrun — client realm.
+(function () {
+ const TEAM_RUNNERS = 2, TEAM_ACTIVATORS = 3;
+
+ const GM = {
+ mode: "deathrun",
+ name: "OpenVibe Deathrun (client)",
+ Initialize() { OV.log("Deathrun client Initialize"); }
+ };
+
+ if (globalThis.net && net.__openvibe) {
+ net.Receive("OV_DR_Role", function () {
+ const teamId = net.ReadInt();
+ const role = teamId === TEAM_ACTIVATORS ? "ACTIVATOR" : "RUNNER";
+ OV.log("[DR] You are the " + role);
+ hook.Run("OVRoleAssigned", role, teamId, 0);
+ if (OV.menuJS) OV.menuJS('window.OV&&OV.onRole&&OV.onRole("' + role + '",' + (teamId | 0) + ',0)');
+ });
+ }
+
+ gamemode.set(GM);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "id": "deathrun",
+ "name": "OpenVibe Deathrun",
+ "base": "base",
+ "category": "pvp",
+ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },
+ "settings": [
+ { "name": "ov_dr_round_duration", "text": "Round duration (s)", "type": "Numeric", "default": 240 }
+ ]
+}
@@ -29,6 +29,13 @@
@@ -29,6 +29,13 @@
} else {
} else {
p.setTeam(TEAM_RUNNERS);
p.setTeam(TEAM_RUNNERS);
}
}
+ if (globalThis.net && net.__openvibe) {
+ try {
+ net.Start("OV_DR_Role");
+ net.WriteInt(p.team());
+ net.Send(p);
+ } catch (e) { /* transport not up */ }
+ }
});
});
runnersAlive = players.filter((p) => p.team() === TEAM_RUNNERS).length;
runnersAlive = players.filter((p) => p.team() === TEAM_RUNNERS).length;
@@ -54,6 +61,14 @@
@@ -54,6 +61,14 @@
Initialize() {
Initialize() {
OV.log("Deathrun Initialize fired");
OV.log("Deathrun Initialize fired");
registerCommands();
registerCommands();
+ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_DR_Role");
+ },
+
+ CreateTeams() {
+ if (!globalThis.team) return;
+ team.SetUp(0, "Unassigned", Color(200, 200, 200));
+ team.SetUp(TEAM_RUNNERS, "Runners", Color(80, 160, 240));
+ team.SetUp(TEAM_ACTIVATORS, "Activator", Color(240, 80, 80));
},
},
MapInitialize(mapName) {
MapInitialize(mapName) {
@@ -68,6 +83,7 @@
@@ -68,6 +83,7 @@
startRound() {
startRound() {
this._roundNumber += 1;
this._roundNumber += 1;
this._roundState = "active";
this._roundState = "active";
+ this._roundEndsAt = OV.time() + ROUND_DURATION;
activatorId = null;
activatorId = null;
assignTeams();
assignTeams();
@@ -92,6 +108,7 @@
@@ -92,6 +108,7 @@
endRound(reason) {
endRound(reason) {
if (this._roundState !== "active") return;
if (this._roundState !== "active") return;
this._roundState = "ended";
this._roundState = "ended";
+ this._roundEndsAt = 0;
timer.remove(`ov_dr_round_end_${this._roundNumber}`);
timer.remove(`ov_dr_round_end_${this._roundNumber}`);
let msg;
let msg;
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+// OpenVibe Fort Wars — client realm.
+(function () {
+ const GM = {
+ mode: "fortwars",
+ name: "OpenVibe Fort Wars (client)",
+ Initialize() { OV.log("Fort Wars client Initialize"); }
+ };
+
+ if (globalThis.net && net.__openvibe) {
+ net.Receive("OV_FW_Phase", function () {
+ const phase = net.ReadString();
+ const seconds = net.ReadInt();
+ OV.log("[FW] Phase: " + phase + " (" + seconds + "s)");
+ hook.Run("OVPhaseChanged", phase, seconds);
+ if (OV.menuJS) OV.menuJS('window.OV&&OV.onPhase&&OV.onPhase("' + phase + '",' + (seconds | 0) + ')');
+ });
+ }
+
+ gamemode.set(GM);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+{
+ "id": "fortwars",
+ "name": "OpenVibe Fort Wars",
+ "base": "base",
+ "category": "pvp",
+ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },
+ "settings": [
+ { "name": "ov_fw_build_duration", "text": "Build phase (s)", "type": "Numeric", "default": 60 },
+ { "name": "ov_fw_fight_duration", "text": "Fight phase (s)", "type": "Numeric", "default": 180 }
+ ]
+}
@@ -27,6 +27,16 @@
@@ -27,6 +27,16 @@
OV.broadcast(`Fort Wars: Red (${redAlive}) vs Blue (${blueAlive})`);
OV.broadcast(`Fort Wars: Red (${redAlive}) vs Blue (${blueAlive})`);
}
}
+ function broadcastPhase(name, seconds) {
+ if (!globalThis.net || !net.__openvibe) return;
+ try {
+ net.Start("OV_FW_Phase");
+ net.WriteString(name);
+ net.WriteInt(seconds | 0);
+ net.Broadcast();
+ } catch (e) { /* transport not up */ }
+ }
+
function registerCommands() {
function registerCommands() {
if (!globalThis.command) return;
if (!globalThis.command) return;
@@ -58,6 +68,14 @@
@@ -58,6 +68,14 @@
Initialize() {
Initialize() {
OV.log("Fort Wars Initialize fired");
OV.log("Fort Wars Initialize fired");
registerCommands();
registerCommands();
+ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_FW_Phase");
+ },
+
+ CreateTeams() {
+ if (!globalThis.team) return;
+ team.SetUp(0, "Unassigned", Color(200, 200, 200));
+ team.SetUp(TEAM_RED, "Red", Color(235, 75, 60));
+ team.SetUp(TEAM_BLUE, "Blue", Color(60, 130, 235));
},
},
MapInitialize(mapName) {
MapInitialize(mapName) {
@@ -72,7 +90,9 @@
@@ -72,7 +90,9 @@
startRound() {
startRound() {
this._roundNumber += 1;
this._roundNumber += 1;
this._roundState = "active";
this._roundState = "active";
+ this._roundEndsAt = OV.time() + BUILD_DURATION + FIGHT_DURATION;
phase = "build";
phase = "build";
+ broadcastPhase("build", BUILD_DURATION);
assignTeams();
assignTeams();
@@ -91,6 +111,7 @@
@@ -91,6 +111,7 @@
OV.broadcast("BUILD PHASE OVER! FIGHT!");
OV.broadcast("BUILD PHASE OVER! FIGHT!");
OV.serverCommand("ov_fortwars_build_enabled 0");
OV.serverCommand("ov_fortwars_build_enabled 0");
hook.Run("FW_FightPhaseStart", roundNum);
hook.Run("FW_FightPhaseStart", roundNum);
+ broadcastPhase("fight", FIGHT_DURATION);
});
});
// End of round timer
// End of round timer
@@ -110,6 +131,7 @@
@@ -110,6 +131,7 @@
endRound(reason) {
endRound(reason) {
if (this._roundState !== "active") return;
if (this._roundState !== "active") return;
this._roundState = "ended";
this._roundState = "ended";
+ this._roundEndsAt = 0;
timer.remove(`ov_fw_round_end_${this._roundNumber}`);
timer.remove(`ov_fw_round_end_${this._roundNumber}`);
phase = "build";
phase = "build";
OV.serverCommand("ov_fortwars_build_enabled 1");
OV.serverCommand("ov_fortwars_build_enabled 1");
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+// OpenVibe Hub — client realm.
+(function () {
+ const GM = {
+ mode: "hub",
+ name: "OpenVibe Hub (client)",
+ Initialize() { OV.log("Hub client Initialize"); }
+ };
+ gamemode.set(GM);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+// OpenVibe Prop Hunt — client realm.
+(function () {
+ const TEAM_PROPS = 2, TEAM_HUNTERS = 3;
+
+ const GM = {
+ mode: "prophunt",
+ name: "OpenVibe Prop Hunt (client)",
+ Initialize() { OV.log("Prop Hunt client Initialize"); }
+ };
+
+ if (globalThis.net && net.__openvibe) {
+ net.Receive("OV_PH_Role", function () {
+ const teamId = net.ReadInt();
+ const lockSecs = net.ReadInt();
+ const role = teamId === TEAM_HUNTERS ? "HUNTER" : "PROP";
+ OV.log("[PH] You are a " + role + (teamId === TEAM_HUNTERS ? " (released in " + lockSecs + "s)" : " — hide!"));
+ hook.Run("OVRoleAssigned", role, teamId, lockSecs);
+ if (OV.menuJS) {
+ OV.menuJS('window.OV&&OV.onRole&&OV.onRole("' + role + '",' + (teamId | 0) + ',' + (lockSecs | 0) + ')');
+ }
+ });
+ }
+
+ gamemode.set(GM);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+{
+ "id": "prophunt",
+ "name": "OpenVibe Prop Hunt",
+ "base": "base",
+ "category": "pvp",
+ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },
+ "settings": [
+ { "name": "ov_ph_round_duration", "text": "Round duration (s)", "type": "Numeric", "default": 180 },
+ { "name": "ov_ph_hunter_lock", "text": "Hunter lock time (s)", "type": "Numeric", "default": 20 }
+ ]
+}
@@ -32,7 +32,17 @@
@@ -32,7 +32,17 @@
const shuffled = players.slice().sort(() => Math.random() - 0.5);
const shuffled = players.slice().sort(() => Math.random() - 0.5);
shuffled.forEach(function (p, i) {
shuffled.forEach(function (p, i) {
- p.setTeam(i < hunterCount ? TEAM_HUNTERS : TEAM_PROPS);
+ const t = i < hunterCount ? TEAM_HUNTERS : TEAM_PROPS;
+ p.setTeam(t);
+ // Private role notification over the net library (client HUD shows it).
+ if (globalThis.net && net.__openvibe) {
+ try {
+ net.Start("OV_PH_Role");
+ net.WriteInt(t);
+ net.WriteInt(HUNTER_LOCK_DURATION);
+ net.Send(p);
+ } catch (e) { /* transport not up */ }
+ }
});
});
countAlive();
countAlive();
@@ -66,6 +76,14 @@
@@ -66,6 +76,14 @@
Initialize() {
Initialize() {
OV.log("Prop Hunt Initialize fired");
OV.log("Prop Hunt Initialize fired");
registerCommands();
registerCommands();
+ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_PH_Role");
+ },
+
+ CreateTeams() {
+ if (!globalThis.team) return;
+ team.SetUp(0, "Unassigned", Color(200, 200, 200));
+ team.SetUp(TEAM_PROPS, "Props", Color(80, 200, 120));
+ team.SetUp(TEAM_HUNTERS, "Hunters", Color(220, 90, 60));
},
},
MapInitialize(mapName) {
MapInitialize(mapName) {
@@ -80,6 +98,7 @@
@@ -80,6 +98,7 @@
startRound() {
startRound() {
this._roundNumber += 1;
this._roundNumber += 1;
this._roundState = "active";
this._roundState = "active";
+ this._roundEndsAt = OV.time() + ROUND_DURATION;
locked = true;
locked = true;
assignTeams();
assignTeams();
@@ -112,6 +131,7 @@
@@ -112,6 +131,7 @@
endRound(reason) {
endRound(reason) {
if (this._roundState !== "active") return;
if (this._roundState !== "active") return;
this._roundState = "ended";
this._roundState = "ended";
+ this._roundEndsAt = 0;
locked = false;
locked = false;
timer.remove(`ov_ph_round_end_${this._roundNumber}`);
timer.remove(`ov_ph_round_end_${this._roundNumber}`);
@@ -1,24 +1,41 @@
@@ -1,24 +1,41 @@
-// OpenVibe Sandbox — CLIENT realm (runs inside client.dll QuickJS now).
-OV.log("sandbox client.js running — realm server?=" + OV.isServer());
+// OpenVibe Sandbox — client realm.
+(function () {
+ OV.log("sandbox client.js running — realm server?=" + OV.isServer());
-// npm module usage on the CLIENT
-try {
- var leftpad = require("ov-leftpad");
- OV.log("client require('ov-leftpad')('9',4,'0')=" + leftpad("9", 4, "0"));
-} catch (e) { OV.error("client require failed: " + e.message); }
+ // npm module usage on the CLIENT
+ try {
+ var leftpad = require("ov-leftpad");
+ OV.log("client require('ov-leftpad')('9',4,'0')=" + leftpad("9", 4, "0"));
+ } catch (e) { OV.error("client require failed: " + e.message); }
-// server -> client net: receive a welcome the server pushes on spawn
-if (globalThis.net) {
- net.Receive("OV_Sandbox_Welcome", function (len) {
- var msg = net.ReadString();
- OV.log("CLIENT received OV_Sandbox_Welcome: " + msg);
- });
- OV.log("client net.Receive('OV_Sandbox_Welcome') ready");
-}
+ const GM = {
+ mode: "sandbox",
+ name: "OpenVibe Sandbox (client)",
-// client Initialize hook
-if (globalThis.hook) {
- hook.Add("Initialize", "SandboxClientInit", function () {
- OV.log("client Initialize hook fired");
- });
-}
+ Initialize() {
+ OV.log("Sandbox client Initialize");
+ }
+ };
+
+ // server -> client net: receive a welcome the server pushes on spawn
+ if (globalThis.net) {
+ net.Receive("OV_Sandbox_Welcome", function (len) {
+ var msg = net.ReadString();
+ OV.log("CLIENT received OV_Sandbox_Welcome: " + msg);
+ });
+ OV.log("client net.Receive('OV_Sandbox_Welcome') ready");
+ }
+
+ // Q-menu spawn request: the GUI (or a keybind) triggers this; the server
+ // validates the prop id — we never trust the client value there.
+ if (globalThis.concommand) {
+ concommand.Add("ov_qmenu_spawn", function (_ply, _cmd, args) {
+ if (!globalThis.net) return;
+ net.Start("OV_Sandbox_Spawn");
+ net.WriteString(String(args[0] || "crate"));
+ net.SendToServer();
+ }, null, "Request a sandbox prop spawn from the server");
+ }
+
+ gamemode.set(GM);
+})();
@@ -92,15 +92,45 @@
@@ -92,15 +92,45 @@
return false;
return false;
});
});
});
});
+
+ // Entity-system spawn: !ent <class> — demonstrates ents.Create end to end
+ // (scripted entities like ov_bouncy_crate, or allowlisted engine classes
+ // when the native entity bridge is compiled in).
+ command.add("ent", "Spawn an entity: !ent ov_bouncy_crate", function ({ ply, args, reply }) {
+ const cls = String(args[0] || "ov_bouncy_crate");
+ const ent = ents.Create(cls);
+ if (!IsValid(ent)) {
+ reply(ply, `Unknown entity class: ${cls}`);
+ return false;
+ }
+ if (ply) {
+ const p = ply.GetPos ? ply.GetPos() : { x: 0, y: 0, z: 0 };
+ const fwd = ply.GetForward ? ply.GetForward() : { x: 1, y: 0, z: 0 };
+ ent.SetPos({ x: p.x + fwd.x * 96, y: p.y + fwd.y * 96, z: p.z + 32 });
+ }
+ ent.Spawn();
+ ent.Activate();
+ reply(ply, `Spawned ${cls} (entindex ${ent.EntIndex()})`);
+ return false;
+ });
+
+ command.add("ents", "Count live entities", function ({ ply, reply }) {
+ reply(ply, `Entities: ${ents.GetCount()} (${ents.FindByClass("ov_*").length} scripted ov_*)`);
+ return false;
+ });
}
}
// Networked Q-menu spawn: the client sends the chosen prop via the net
// Networked Q-menu spawn: the client sends the chosen prop via the net
// library (ov_net OV_Sandbox_Spawn <payload>). spawnProp already validates
// library (ov_net OV_Sandbox_Spawn <payload>). spawnProp already validates
// the prop id against the allowlist, so we never trust the client's value.
// the prop id against the allowlist, so we never trust the client's value.
- function registerNet() {
- if (!globalThis.net) return;
+ // Pool at file scope (GMod convention: before any net.Start can run).
+ if (globalThis.util && util.AddNetworkString) {
util.AddNetworkString("OV_Sandbox_Spawn");
util.AddNetworkString("OV_Sandbox_Spawn");
util.AddNetworkString("OV_Sandbox_Welcome");
util.AddNetworkString("OV_Sandbox_Welcome");
+ }
+
+ function registerNet() {
+ if (!globalThis.net) return;
net.Receive("OV_Sandbox_Spawn", function (len, ply) {
net.Receive("OV_Sandbox_Spawn", function (len, ply) {
var id = net.ReadString();
var id = net.ReadString();
if (!ply) { OV.warn("OV_Sandbox_Spawn with no player; ignoring"); return; }
if (!ply) { OV.warn("OV_Sandbox_Spawn with no player; ignoring"); return; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+// OpenVibe Traitor Town — client realm.
+(function () {
+ const TEAM_TRAITOR = 3;
+
+ const GM = {
+ mode: "traitortown",
+ name: "OpenVibe Traitor Town (client)",
+ Initialize() { OV.log("Traitor Town client Initialize"); }
+ };
+
+ if (globalThis.net && net.__openvibe) {
+ net.Receive("OV_TTT_Role", function () {
+ const teamId = net.ReadInt();
+ const role = teamId === TEAM_TRAITOR ? "TRAITOR" : "INNOCENT";
+ // Role stays local to this client — never broadcast.
+ OV.log("[TTT] Your role: " + role);
+ hook.Run("OVRoleAssigned", role, teamId, 0);
+ if (OV.menuJS) OV.menuJS('window.OV&&OV.onRole&&OV.onRole("' + role + '",' + (teamId | 0) + ',0)');
+ });
+ }
+
+ gamemode.set(GM);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+{
+ "id": "traitortown",
+ "name": "OpenVibe Traitor Town",
+ "base": "base",
+ "category": "pvp",
+ "entry": { "shared": "shared.js", "server": "server.js", "client": "client.js" },
+ "settings": [
+ { "name": "ov_ttt_round_duration", "text": "Round duration (s)", "type": "Numeric", "default": 300 },
+ { "name": "ov_ttt_traitor_ratio", "text": "Players per traitor", "type": "Numeric", "default": 4 }
+ ]
+}
@@ -32,9 +32,17 @@
@@ -32,9 +32,17 @@
traitorsAlive = shuffled.filter((p) => p.team() === TEAM_TRAITOR).length;
traitorsAlive = shuffled.filter((p) => p.team() === TEAM_TRAITOR).length;
rolesAssigned = true;
rolesAssigned = true;
- // Tell traitors who they are (privately via chat)
- shuffled.filter((p) => p.team() === TEAM_TRAITOR).forEach(function (p) {
- p.chat("[TRAITOR] You are a traitor. Eliminate all innocents.");
+ // Tell everyone their role privately: chat for traitors + a net message
+ // per player so the client HUD can render it.
+ shuffled.forEach(function (p) {
+ if (p.team() === TEAM_TRAITOR) p.chat("[TRAITOR] You are a traitor. Eliminate all innocents.");
+ if (globalThis.net && net.__openvibe) {
+ try {
+ net.Start("OV_TTT_Role");
+ net.WriteInt(p.team());
+ net.Send(p);
+ } catch (e) { /* transport not up */ }
+ }
});
});
OV.broadcast(`Round ${gamemode.get()._roundNumber} — ${players.length} players. Find the traitor(s)!`);
OV.broadcast(`Round ${gamemode.get()._roundNumber} — ${players.length} players. Find the traitor(s)!`);
@@ -69,6 +77,14 @@
@@ -69,6 +77,14 @@
Initialize() {
Initialize() {
OV.log("Traitor Town Initialize fired");
OV.log("Traitor Town Initialize fired");
registerCommands();
registerCommands();
+ if (globalThis.util && util.AddNetworkString) util.AddNetworkString("OV_TTT_Role");
+ },
+
+ CreateTeams() {
+ if (!globalThis.team) return;
+ team.SetUp(0, "Unassigned", Color(200, 200, 200));
+ team.SetUp(TEAM_INNOCENT, "Innocents", Color(60, 180, 90));
+ team.SetUp(TEAM_TRAITOR, "Traitors", Color(200, 40, 40));
},
},
MapInitialize(mapName) {
MapInitialize(mapName) {
@@ -83,6 +99,7 @@
@@ -83,6 +99,7 @@
startRound() {
startRound() {
this._roundNumber += 1;
this._roundNumber += 1;
this._roundState = "active";
this._roundState = "active";
+ this._roundEndsAt = OV.time() + ROUND_DURATION;
rolesAssigned = false;
rolesAssigned = false;
innocentsAlive = 0;
innocentsAlive = 0;
traitorsAlive = 0;
traitorsAlive = 0;
@@ -105,6 +122,7 @@
@@ -105,6 +122,7 @@
endRound(reason) {
endRound(reason) {
if (this._roundState !== "active") return;
if (this._roundState !== "active") return;
this._roundState = "ended";
this._roundState = "ended";
+ this._roundEndsAt = 0;
rolesAssigned = false;
rolesAssigned = false;
timer.remove(`ov_ttt_round_end_${this._roundNumber}`);
timer.remove(`ov_ttt_round_end_${this._roundNumber}`);
deleted file mode 100644
deleted file mode 100644
@@ -1,6 +0,0 @@
@@ -1,6 +0,0 @@
-module.exports = function leftpad(str, len, ch) {
- str = String(str);
- ch = ch || " ";
- while (str.length < len) str = ch + str;
- return str;
-};
deleted file mode 100644
deleted file mode 100644
@@ -1 +0,0 @@
@@ -1 +0,0 @@
-{ "name": "ov-leftpad", "version": "1.0.0", "main": "index.js" }
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+{
+ "name": "openvibe-game-scripts",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-game-scripts",
+ "dependencies": {
+ "is-odd": "^3.0.1",
+ "ov-leftpad": "file:vendor/ov-leftpad"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz",
+ "integrity": "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-odd": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",
+ "integrity": "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/ov-leftpad": {
+ "resolved": "vendor/ov-leftpad",
+ "link": true
+ },
+ "vendor/ov-leftpad": {
+ "version": "1.0.0"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "name": "openvibe-game-scripts",
+ "private": true,
+ "description": "npm root for the OpenVibe GModJS runtime (js/node_modules is require()-able from gamemodes, addons, and entities in both backends).",
+ "dependencies": {
+ "is-odd": "^3.0.1",
+ "ov-leftpad": "file:vendor/ov-leftpad"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+module.exports = function leftpad(str, len, ch) {
+ str = String(str);
+ ch = ch || " ";
+ while (str.length < len) str = ch + str;
+ return str;
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "name": "ov-leftpad", "version": "1.0.0", "main": "index.js" }
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+// Shadow material: stock HL2 charger glow sprite that the dedicated server's
+// mounted content doesn't expose to precache. Mirrors hl2/materials/sprites/
+// orangelight1_noz.vmt using a base texture that ships with HL2MP content.
+"UnlitGeneric"
+{
+ "$basetexture" "sprites/glow01"
+ "$spriteorientation" "vp_parallel"
+ "$spriterendermode" "9"
+ "$ignorez" "1"
+ "$nofog" "1"
+}
@@ -1,47 +1,84 @@
@@ -1,47 +1,84 @@
# OpenVibe Unified UI Shell
# OpenVibe Unified UI Shell
-The Electron launcher and the in-game Source/VGUI embedded browser now load the same web shell.
+The Electron launcher, the in-game Source/VGUI embedded browser, and the
+backend all serve/load the SAME app: the repo-root `client/` directory
+(`client/index.html` + `client/ui-sync.js`).
## Hosts
## Hosts
- Electron loads `http://127.0.0.1:5173/client/?electron=1&shell=electron#portal`
- Electron loads `http://127.0.0.1:5173/client/?electron=1&shell=electron#portal`
+ (window.OV = preload IPC bridge; the app object is `window.OVApp`)
- Source/VGUI loads `http://127.0.0.1:5173/client/?embedded=1&shell=source#portal`
- Source/VGUI loads `http://127.0.0.1:5173/client/?embedded=1&shell=source#portal`
+ (window.OV = the app object, talks via `openvibe://` navigations)
+- The backend serves the identical files at `http://127.0.0.1:3000/client/`
+- HUD overlay: append `&hud=1` (or `#hud`) — translucent, pointer-events none
-Both are served by `tools/serve-client-ui.mjs` from the `launcher/` folder.
+Port 5173 is `tools/serve-client-ui.mjs`, which now serves the repo `client/`
+directory for `/client/*` (plus `/health` and `launcher/assets` at `/assets/*`).
+
+## Routes
+
+`portal, servers, leaderboard, inventory, shop, settings, options, console, hud`
+
+- `#console` — merged SSE log view (server runtime 127.0.0.1:41997/logs,
+ client runtime 127.0.0.1:41996/logs), JS eval per realm (`POST /eval`),
+ engine commands via `openvibe://cmd?c=` in-game or `POST 41997/exec`
+ elsewhere; `]cmd` forces engine, `npm …` goes to `POST /npm`.
+- `#options` — game convars (allowlisted, `openvibe://convar?name=&value=`,
+ current values pushed via `window.OV.onConvars`), UI prefs
+ (`localStorage["openvibe.options.v1"]`, synced across hosts), account
+ fields, and the active gamemode's `manifest.json settings[]` from
+ `GET 41997/state`. Account auth stays on `#settings`.
+- `#hud` / `?hud=1` — round banner, team + timer, health, gamemode name;
+ driven by `window.OV.onHudState(obj)` with `GET 41996/41997 /state` polling
+ as fallback.
## In-game commands
## In-game commands
-- `ov_ui`
-- `ov_main_menu`
-- `ov_menu_main`
-- `ov_menu_servers`
-- `ov_menu_leaderboard`
-- `ov_menu_inventory`
-- `ov_menu_shop`
-- `ov_menu_settings`
-- `ov_menu_close`
-- `ov_menu_reload`
+- `ov_ui`, `ov_main_menu`, `ov_menu_main`
+- `ov_menu_servers`, `ov_menu_leaderboard`, `ov_menu_inventory`,
+ `ov_menu_shop`, `ov_menu_settings`, `ov_menu_options`, `ov_menu_console`
+- `ov_menu_close`, `ov_menu_reload`
## Bridge URLs
## Bridge URLs
The in-game HTML panel accepts allowlisted `openvibe://` actions:
The in-game HTML panel accepts allowlisted `openvibe://` actions:
-- `openvibe://join?mode=hub`
-- `openvibe://join?mode=prophunt`
-- `openvibe://join?mode=deathrun`
-- `openvibe://join?mode=fortwars`
-- `openvibe://join?mode=traitortown`
-- `openvibe://close`
-- `openvibe://reload`
+- `openvibe://join?mode=<hub|prophunt|deathrun|fortwars|traitortown>`
+- `openvibe://cmd?c=<urlencoded engine command>` (allowlisted in C++)
+- `openvibe://convar?name=<name>&value=<value>` (allowlisted convars)
+- `openvibe://convar_get?names=<a,b,c>` → C++ replies via `window.OV.onConvars`
+- `openvibe://close`, `openvibe://reload`, `openvibe://ready`
+
+Electron receives equivalent actions through the `window.OV` preload bridge
+(`launcher/preload.js`); the `ui:set-route` allowlist in `launcher/main.js`
+includes `options` and `console`.
+
+C++ push entry points on the page (in-game): `window.OV.onConsoleLine(lines)`,
+`window.OV.onConvars(obj)`, `window.OV.onHudState(obj)`.
-Electron receives equivalent actions through `window.OV` in `preload.js`.
+## Keyboard
+
+- `F1` portal, `F2` servers
+- `` ` `` or `F10` toggles the console route
+- `Esc` → `openvibe://close` (in-game)
## Sync model
## Sync model
-`launcher/openvibe-ui-sync.js` keeps route state in:
+`client/ui-sync.js` keeps route + UI-pref state in:
- `location.hash`
- `location.hash`
- `localStorage["openvibe.ui.state.v1"]`
- `localStorage["openvibe.ui.state.v1"]`
- `BroadcastChannel("openvibe-ui-sync-v1")`
- `BroadcastChannel("openvibe-ui-sync-v1")`
-That gives Electron and in-game the same route/state contract without duplicating UI code.
+That gives Electron and in-game the same route/state contract without
+duplicating UI code. The `hud` route is never applied remotely.
+
+## Test
+
+`node tools/smoke-ui.mjs` — serves the app on an ephemeral port and asserts
+all route markers, the sync layer, `/health`, and the backend's on-disk
+`client/index.html` mapping.
+
+`launcher/index.html` + `renderer.js` remain only as the offline fallback
+Electron loads if the UI server cannot start.
@@ -368,7 +368,7 @@ ipcMain.handle('launcher:show', async () => {
@@ -368,7 +368,7 @@ ipcMain.handle('launcher:show', async () => {
ipcMain.on('open-url', (_e, url) => shell.openExternal(url));
ipcMain.on('open-url', (_e, url) => shell.openExternal(url));
ipcMain.on('ui:set-route', (_e, route) => {
ipcMain.on('ui:set-route', (_e, route) => {
- const safe = ['portal', 'servers', 'leaderboard', 'inventory', 'shop', 'settings'].includes(route) ? route : 'portal';
+ const safe = ['portal', 'servers', 'leaderboard', 'inventory', 'shop', 'settings', 'options', 'console'].includes(route) ? route : 'portal';
mainWindow?.webContents.send('ui:set-route', safe);
mainWindow?.webContents.send('ui:set-route', safe);
});
});
@@ -8,6 +8,7 @@
@@ -8,6 +8,7 @@
// panel. This is a thin, crash-proof bridge.
// panel. This is a thin, crash-proof bridge.
#include "openvibe/ov_ipc.h"
#include "openvibe/ov_ipc.h"
#include "usermessages.h"
#include "usermessages.h"
+#include "c_baseplayer.h"
#include <stdlib.h>
#include <stdlib.h>
#include <string.h>
#include <string.h>
@@ -25,6 +26,7 @@ static ConVar ov_client_mode(
@@ -25,6 +26,7 @@ static ConVar ov_client_mode(
static COpenVibeIPC g_ClientIPC;
static COpenVibeIPC g_ClientIPC;
static bool g_bClientBridgeStarted = false;
static bool g_bClientBridgeStarted = false;
+static bool g_bSentLocalPlayer = false;
// ---- tiny JSON helpers (build + extract) ----
// ---- tiny JSON helpers (build + extract) ----
static void OVJSON_AppendEscaped( char *dst, int dstSize, const char *src )
static void OVJSON_AppendEscaped( char *dst, int dstSize, const char *src )
@@ -115,11 +117,38 @@ void __MsgFunc_OVNet( bf_read &msg )
@@ -115,11 +117,38 @@ void __MsgFunc_OVNet( bf_read &msg )
g_ClientIPC.SendLine( out );
g_ClientIPC.SendLine( out );
}
}
+// ---- outbound: local player identity -> runtime ----
+// Sent once per level, as soon as the local player entity is valid (it isn't
+// yet at LevelInitPostEntity, so OpenVibeJS_Client_Think retries).
+static void OVClient_TrySendLocalPlayer()
+{
+ if ( g_bSentLocalPlayer || !g_ClientIPC.IsConnected() ) return;
+
+ C_BasePlayer *pLocal = C_BasePlayer::GetLocalPlayer();
+ if ( !pLocal ) return;
+
+ player_info_t pi;
+ if ( !engine->GetPlayerInfo( pLocal->entindex(), &pi ) ) return;
+
+ char out[512];
+ Q_snprintf( out, sizeof( out ), "{\"t\":\"local_player\",\"userId\":%d,\"name\":\"", pi.userID );
+ OVJSON_AppendEscaped( out, sizeof( out ), pi.name );
+ Q_strncat( out, "\",\"steamId\":\"", sizeof( out ), COPY_ALL_CHARACTERS );
+ OVJSON_AppendEscaped( out, sizeof( out ), pi.guid );
+ char tail[48];
+ Q_snprintf( tail, sizeof( tail ), "\",\"entIndex\":%d}", pLocal->entindex() );
+ Q_strncat( out, tail, sizeof( out ), COPY_ALL_CHARACTERS );
+
+ g_ClientIPC.SendLine( out );
+ g_bSentLocalPlayer = true;
+}
+
// ---- lifecycle ----
// ---- lifecycle ----
void OpenVibeJS_Client_Init()
void OpenVibeJS_Client_Init()
{
{
if ( g_bClientBridgeStarted ) return;
if ( g_bClientBridgeStarted ) return;
g_bClientBridgeStarted = true;
g_bClientBridgeStarted = true;
+ g_bSentLocalPlayer = false;
if ( !ov_client_js_enabled.GetBool() )
if ( !ov_client_js_enabled.GetBool() )
{
{
Msg( "[OV JS/client] bridge disabled by ov_client_js_enabled=0\n" );
Msg( "[OV JS/client] bridge disabled by ov_client_js_enabled=0\n" );
@@ -145,6 +174,19 @@ void OpenVibeJS_Client_Shutdown()
@@ -145,6 +174,19 @@ void OpenVibeJS_Client_Shutdown()
void OpenVibeJS_Client_Think()
void OpenVibeJS_Client_Think()
{
{
g_ClientIPC.Poll();
g_ClientIPC.Poll();
+
+ if ( !g_ClientIPC.IsConnected() )
+ return;
+
+ OVClient_TrySendLocalPlayer();
+
+ // 10Hz think to the client runtime (mirrors the server bridge).
+ static float s_flNextThink = 0.0f;
+ if ( gpGlobals && gpGlobals->curtime >= s_flNextThink )
+ {
+ s_flNextThink = gpGlobals->curtime + 0.1f;
+ g_ClientIPC.SendLine( "{\"t\":\"think\"}" );
+ }
}
}
static void OV_ClientJSStatus_f()
static void OV_ClientJSStatus_f()
@@ -165,6 +207,72 @@ static void OV_ClientJSReconnect_f()
@@ -165,6 +207,72 @@ static void OV_ClientJSReconnect_f()
static ConCommand ov_client_js_reconnect( "ov_client_js_reconnect", OV_ClientJSReconnect_f,
static ConCommand ov_client_js_reconnect( "ov_client_js_reconnect", OV_ClientJSReconnect_f,
"Reconnect the OpenVibe client Node runtime bridge.", FCVAR_CLIENTDLL );
"Reconnect the OpenVibe client Node runtime bridge.", FCVAR_CLIENTDLL );
+// ---- js_run_cl / js_openscript_cl — gated by the replicated sv_allowcsjs ----
+// sv_allowcsjs (GMod's sv_allowcslua) is registered server-side with
+// FCVAR_REPLICATED; read it through the cvar system so the server's value
+// applies. Unknown (not connected / not registered) counts as allowed —
+// the dev default is 1.
+static bool OVClient_CSJSAllowed()
+{
+ const ConVar *pAllow = g_pCVar ? g_pCVar->FindVar( "sv_allowcsjs" ) : NULL;
+ if ( pAllow && !pAllow->GetBool() )
+ {
+ Warning( "[OV JS/client] blocked: server has sv_allowcsjs 0\n" );
+ return false;
+ }
+ return true;
+}
+
+static void OV_JSRunCl_f( const CCommand &args )
+{
+ if ( args.ArgC() < 2 )
+ {
+ Msg( "Usage: js_run_cl <code>\n" );
+ return;
+ }
+
+ if ( !OVClient_CSJSAllowed() )
+ return;
+
+ if ( !g_ClientIPC.IsConnected() )
+ {
+ Warning( "[OV JS/client] runtime not connected; js_run_cl ignored\n" );
+ return;
+ }
+
+ char out[4096] = "{\"t\":\"eval\",\"code\":\"";
+ OVJSON_AppendEscaped( out, sizeof( out ), args.ArgS() );
+ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ClientIPC.SendLine( out );
+}
+static ConCommand js_run_cl( "js_run_cl", OV_JSRunCl_f,
+ "Run JavaScript in the client realm: js_run_cl <code>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );
+
+static void OV_JSOpenScriptCl_f( const CCommand &args )
+{
+ if ( args.ArgC() < 2 )
+ {
+ Msg( "Usage: js_openscript_cl <path relative to js/>\n" );
+ return;
+ }
+
+ if ( !OVClient_CSJSAllowed() )
+ return;
+
+ if ( !g_ClientIPC.IsConnected() )
+ {
+ Warning( "[OV JS/client] runtime not connected; js_openscript_cl ignored\n" );
+ return;
+ }
+
+ char out[1024] = "{\"t\":\"openscript\",\"path\":\"";
+ OVJSON_AppendEscaped( out, sizeof( out ), args[1] );
+ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ClientIPC.SendLine( out );
+}
+static ConCommand js_openscript_cl( "js_openscript_cl", OV_JSOpenScriptCl_f,
+ "Run a script file in the client realm: js_openscript_cl <path relative to js/>. Gated by sv_allowcsjs.", FCVAR_CLIENTDLL );
+
// Drives the bridge: hook OVNet, connect on level enter, poll each frame.
// Drives the bridge: hook OVNet, connect on level enter, poll each frame.
class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame
class COpenVibeClientJSSystem : public CAutoGameSystemPerFrame
{
{
@@ -25,6 +25,11 @@ using namespace vgui;
@@ -25,6 +25,11 @@ using namespace vgui;
// in this file, so Linux/GCC needs an explicit forward declaration.
// in this file, so Linux/GCC needs an explicit forward declaration.
void OpenVibe_OnHTMLReady();
void OpenVibe_OnHTMLReady();
+// HandleBridgeURL() / OnThink() also use these before their definitions below.
+static void OV_SanitiseJSString( char *pszOut, size_t nOut, const char *pszIn );
+static void OV_InstallConsoleTap();
+static void OV_ConsoleTapThink();
+
static ConVar ov_menu_url(
static ConVar ov_menu_url(
"ov_menu_url",
"ov_menu_url",
@@ -79,6 +84,85 @@ static bool OV_IsSafeMode( const char *pszMode )
@@ -79,6 +84,85 @@ static bool OV_IsSafeMode( const char *pszMode )
!Q_stricmp( pszMode, "traitortown" ) );
!Q_stricmp( pszMode, "traitortown" ) );
}
}
+// openvibe://cmd allowlist: the first token decides. ov_* commands (which
+// includes ov_npm and all OpenVibe dev commands) plus the GModJS script
+// commands and a few stock engine commands the GUI console needs.
+static bool OV_IsAllowedBridgeCommand( const char *pszCommand )
+{
+ if ( !pszCommand || !pszCommand[0] )
+ return false;
+
+ // No multi-command or newline smuggling through the bridge.
+ if ( Q_strstr( pszCommand, ";" ) || Q_strstr( pszCommand, "\n" ) || Q_strstr( pszCommand, "\r" ) )
+ return false;
+
+ char szToken[64];
+ int i = 0;
+ while ( pszCommand[i] && pszCommand[i] != ' ' && i < (int)sizeof( szToken ) - 1 )
+ {
+ szToken[i] = pszCommand[i];
+ ++i;
+ }
+ szToken[i] = '\0';
+
+ if ( !Q_strnicmp( szToken, "ov_", 3 ) )
+ return true;
+
+ static const char *s_AllowedBridgeCommands[] =
+ {
+ "js_run",
+ "js_run_cl",
+ "js_openscript",
+ "js_openscript_cl",
+ "ov_npm",
+ "say",
+ "connect",
+ "disconnect",
+ "retry",
+ "status",
+ };
+
+ for ( int j = 0; j < ARRAYSIZE( s_AllowedBridgeCommands ); ++j )
+ {
+ if ( !Q_stricmp( szToken, s_AllowedBridgeCommands[j] ) )
+ return true;
+ }
+
+ return false;
+}
+
+// openvibe://convar[_get] allowlist: OpenVibe client settings plus the stock
+// game/video/audio convars the options route exposes.
+static const char *s_OVAllowedBridgeConvars[] =
+{
+ "ov_menu_url",
+ "ov_menu_auto_open",
+ "ov_menu_allow_remote",
+ "ov_api_url",
+ "ov_client_js_enabled",
+ "ov_js_backend",
+ "volume",
+ "snd_musicvolume",
+ "mat_monitorgamma",
+ "fov_desired",
+ "sensitivity",
+ "cl_ragdoll_collide",
+};
+
+static bool OV_IsAllowedBridgeConvar( const char *pszName )
+{
+ if ( !pszName || !pszName[0] )
+ return false;
+
+ for ( int i = 0; i < ARRAYSIZE( s_OVAllowedBridgeConvars ); ++i )
+ {
+ if ( !Q_stricmp( pszName, s_OVAllowedBridgeConvars[i] ) )
+ return true;
+ }
+
+ return false;
+}
+
static void OV_URLDecodeInPlace( char *pszValue )
static void OV_URLDecodeInPlace( char *pszValue )
{
{
if ( !pszValue )
if ( !pszValue )
@@ -241,6 +325,12 @@ public:
@@ -241,6 +325,12 @@ public:
MakeFullScreen();
MakeFullScreen();
}
}
+ void OnThink() OVERRIDE
+ {
+ BaseClass::OnThink();
+ OV_ConsoleTapThink();
+ }
+
bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE
bool OnOpenVibeHTMLStartRequest( const char *pszURL, const char *pszTarget, const char *pszPostData, bool bIsRedirect ) OVERRIDE
{
{
if ( OV_URLStartsWith( pszURL, "openvibe://" ) )
if ( OV_URLStartsWith( pszURL, "openvibe://" ) )
@@ -316,9 +406,94 @@ private:
@@ -316,9 +406,94 @@ private:
return;
return;
}
}
+ if ( OV_URLStartsWith( pszURL, "openvibe://cmd" ) )
+ {
+ // GUI console -> engine command, allowlisted by first token.
+ char szCmd[1024];
+ if ( !OV_ReadQueryValue( pszURL, "c", szCmd, sizeof( szCmd ) ) )
+ return;
+
+ if ( !OV_IsAllowedBridgeCommand( szCmd ) )
+ {
+ Warning( "[OV HTML] blocked bridge command: %s\n", szCmd );
+ return;
+ }
+
+ engine->ClientCmd_Unrestricted( VarArgs( "%s\n", szCmd ) );
+ return;
+ }
+
+ // NOTE: convar_get must be checked before the "openvibe://convar" prefix.
+ if ( OV_URLStartsWith( pszURL, "openvibe://convar_get" ) )
+ {
+ char szNames[512];
+ if ( OV_ReadQueryValue( pszURL, "names", szNames, sizeof( szNames ) ) )
+ PushConvarsToPage( szNames );
+ return;
+ }
+
+ if ( OV_URLStartsWith( pszURL, "openvibe://convar" ) )
+ {
+ char szName[64];
+ char szValue[256];
+ if ( !OV_ReadQueryValue( pszURL, "name", szName, sizeof( szName ) ) ||
+ !OV_ReadQueryValue( pszURL, "value", szValue, sizeof( szValue ) ) )
+ return;
+
+ if ( !OV_IsAllowedBridgeConvar( szName ) )
+ {
+ Warning( "[OV HTML] blocked convar set: %s\n", szName );
+ return;
+ }
+
+ ConVar *pVar = g_pCVar ? g_pCVar->FindVar( szName ) : NULL;
+ if ( pVar )
+ pVar->SetValue( szValue );
+ else
+ Warning( "[OV HTML] convar not found: %s\n", szName );
+ return;
+ }
+
Warning( "[OV HTML] Ignored bridge URL: %s\n", pszURL ? pszURL : "<null>" );
Warning( "[OV HTML] Ignored bridge URL: %s\n", pszURL ? pszURL : "<null>" );
}
}
+ // openvibe://convar_get — push allowlisted convar values back into the page
+ // as window.OV.onConvars({name: "value", ...}).
+ void PushConvarsToPage( char *pszNamesCsv )
+ {
+ char szScript[4096];
+ Q_strncpy( szScript,
+ "if(typeof window.OV==='object'&&typeof window.OV.onConvars==='function')"
+ "{window.OV.onConvars({", sizeof( szScript ) );
+
+ bool bFirst = true;
+ for ( char *pszName = strtok( pszNamesCsv, "," ); pszName; pszName = strtok( NULL, "," ) )
+ {
+ while ( *pszName == ' ' )
+ ++pszName;
+
+ // Allowlisted names are literal matches of s_OVAllowedBridgeConvars,
+ // so they are safe to embed; values still go through the sanitiser.
+ if ( !*pszName || !OV_IsAllowedBridgeConvar( pszName ) )
+ continue;
+
+ ConVar *pVar = g_pCVar ? g_pCVar->FindVar( pszName ) : NULL;
+ if ( !pVar )
+ continue;
+
+ char szValue[256];
+ OV_SanitiseJSString( szValue, sizeof( szValue ), pVar->GetString() );
+
+ char szPair[384];
+ Q_snprintf( szPair, sizeof( szPair ), "%s'%s':'%s'", bFirst ? "" : ",", pszName, szValue );
+ Q_strncat( szScript, szPair, sizeof( szScript ), COPY_ALL_CHARACTERS );
+ bFirst = false;
+ }
+
+ Q_strncat( szScript, "});}", sizeof( szScript ), COPY_ALL_CHARACTERS );
+ RunJS( szScript );
+ }
+
HTML *m_pHTML;
HTML *m_pHTML;
};
};
@@ -327,11 +502,121 @@ static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;
@@ -327,11 +502,121 @@ static COpenVibeHTMLPanel *s_pOpenVibeMenu = NULL;
static COpenVibeHTMLPanel *OV_GetHTMLMenu()
static COpenVibeHTMLPanel *OV_GetHTMLMenu()
{
{
if ( !s_pOpenVibeMenu )
if ( !s_pOpenVibeMenu )
+ {
s_pOpenVibeMenu = new COpenVibeHTMLPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );
s_pOpenVibeMenu = new COpenVibeHTMLPanel( enginevgui->GetPanel( PANEL_GAMEUIDLL ) );
+ OV_InstallConsoleTap();
+ }
return s_pOpenVibeMenu;
return s_pOpenVibeMenu;
}
}
+// OPENVIBE_CONSOLE_SPEW_TAP_BEGIN
+//
+// Mirrors engine console output into the HTML GUI console route. The chained
+// tier0 spew hook records EVERY line into a ring buffer from client-mode init
+// (so the GUI console gets full history, replacing the stock Source console);
+// the panel's OnThink drains the backlog into window.OV.onConsoleLine([...])
+// while visible, at most every 0.15s and 24 lines per flush. When the console
+// runs hot the oldest unsent lines fall out of the ring (never blocks).
+
+#define OV_CONSOLE_RING_LINES 400
+#define OV_CONSOLE_FLUSH_MAX 24
+
+static SpewOutputFunc_t s_PrevSpewFunc = NULL;
+static bool s_bConsoleTapInstalled = false;
+static bool s_bInConsoleTap = false; // reentrancy: a Msg inside RunJS must not recurse
+static char s_ConsoleRing[OV_CONSOLE_RING_LINES][480];
+static int64 s_nRingWritten = 0; // total lines ever written
+static int64 s_nRingSent = 0; // total lines ever flushed to the panel
+static float s_flNextConsoleFlush = 0.0f;
+
+static SpewRetval_t OV_ConsoleTapSpewFunc( SpewType_t spewType, const tchar *pMsg )
+{
+ // Always forward to the previous spew func first.
+ SpewRetval_t ret = s_PrevSpewFunc ? s_PrevSpewFunc( spewType, pMsg ) : SPEW_CONTINUE;
+
+ if ( !s_bInConsoleTap && pMsg && pMsg[0] )
+ {
+ s_bInConsoleTap = true;
+
+ char *pszLine = s_ConsoleRing[s_nRingWritten % OV_CONSOLE_RING_LINES];
+ int nWritten = 0;
+ // Tag warnings/errors so the GUI can color them.
+ if ( spewType == SPEW_WARNING || spewType == SPEW_ASSERT || spewType == SPEW_ERROR )
+ {
+ const char *pszTag = ( spewType == SPEW_WARNING ) ? "[warning] " : "[error] ";
+ for ( const char *t = pszTag; *t && nWritten < (int)sizeof( s_ConsoleRing[0] ) - 1; ++t )
+ pszLine[nWritten++] = *t;
+ }
+ for ( const char *p = pMsg; *p && nWritten < (int)sizeof( s_ConsoleRing[0] ) - 1; ++p )
+ pszLine[nWritten++] = ( *p == '\n' || *p == '\r' ) ? ' ' : *p;
+ while ( nWritten > 0 && pszLine[nWritten - 1] == ' ' )
+ --nWritten;
+ pszLine[nWritten] = '\0';
+
+ if ( nWritten > 0 )
+ ++s_nRingWritten;
+
+ s_bInConsoleTap = false;
+ }
+
+ return ret;
+}
+
+static void OV_InstallConsoleTap()
+{
+ if ( s_bConsoleTapInstalled )
+ return;
+
+ s_bConsoleTapInstalled = true;
+ s_PrevSpewFunc = GetSpewOutputFunc();
+ SpewOutputFunc( OV_ConsoleTapSpewFunc );
+}
+
+// Called from COpenVibeHTMLPanel::OnThink (i.e. only while the panel is visible).
+static void OV_ConsoleTapThink()
+{
+ if ( !s_pOpenVibeMenu || s_nRingSent >= s_nRingWritten )
+ return;
+
+ const float flNow = Plat_FloatTime();
+ if ( flNow < s_flNextConsoleFlush )
+ return;
+ s_flNextConsoleFlush = flNow + 0.15f;
+
+ // If the writer lapped us, skip the lines the ring no longer holds.
+ if ( s_nRingWritten - s_nRingSent > OV_CONSOLE_RING_LINES )
+ s_nRingSent = s_nRingWritten - OV_CONSOLE_RING_LINES;
+
+ char szScript[16384];
+ Q_strncpy( szScript,
+ "if(typeof window.OV==='object'&&typeof window.OV.onConsoleLine==='function')"
+ "{window.OV.onConsoleLine([", sizeof( szScript ) );
+
+ int nBatch = 0;
+ while ( s_nRingSent < s_nRingWritten && nBatch < OV_CONSOLE_FLUSH_MAX )
+ {
+ char szLine[480];
+ OV_SanitiseJSString( szLine, sizeof( szLine ), s_ConsoleRing[s_nRingSent % OV_CONSOLE_RING_LINES] );
+ ++s_nRingSent;
+
+ if ( nBatch > 0 )
+ Q_strncat( szScript, ",", sizeof( szScript ), COPY_ALL_CHARACTERS );
+ Q_strncat( szScript, "'", sizeof( szScript ), COPY_ALL_CHARACTERS );
+ Q_strncat( szScript, szLine, sizeof( szScript ), COPY_ALL_CHARACTERS );
+ Q_strncat( szScript, "'", sizeof( szScript ), COPY_ALL_CHARACTERS );
+ ++nBatch;
+ }
+
+ Q_strncat( szScript, "]);}", sizeof( szScript ), COPY_ALL_CHARACTERS );
+
+ // RunJS can itself Msg(); the guard keeps that spew out of the batch.
+ s_bInConsoleTap = true;
+ s_pOpenVibeMenu->RunJS( szScript );
+ s_bInConsoleTap = false;
+}
+// OPENVIBE_CONSOLE_SPEW_TAP_END
+
static void OV_OpenMenu_f( const CCommand &args )
static void OV_OpenMenu_f( const CCommand &args )
{
{
const char *pszURL = args.ArgC() >= 2 ? args[1] : NULL;
const char *pszURL = args.ArgC() >= 2 ? args[1] : NULL;
@@ -358,6 +643,10 @@ static void OV_MenuJS_f( const CCommand &args )
@@ -358,6 +643,10 @@ static void OV_MenuJS_f( const CCommand &args )
void OpenVibe_OnClientModeInit()
void OpenVibe_OnClientModeInit()
{
{
+ // Install the console tap immediately so the GUI console has full history
+ // from process start, even before the panel is first opened.
+ OV_InstallConsoleTap();
+
if ( ov_menu_auto_open.GetBool() )
if ( ov_menu_auto_open.GetBool() )
OV_GetHTMLMenu()->Open();
OV_GetHTMLMenu()->Open();
}
}
@@ -422,6 +711,8 @@ static void OV_MenuLeaderboard_f( const CCommand &args ) { OV_OpenMenuRoute( "le
@@ -422,6 +711,8 @@ static void OV_MenuLeaderboard_f( const CCommand &args ) { OV_OpenMenuRoute( "le
static void OV_MenuInventory_f( const CCommand &args ) { OV_OpenMenuRoute( "inventory" ); }
static void OV_MenuInventory_f( const CCommand &args ) { OV_OpenMenuRoute( "inventory" ); }
static void OV_MenuShop_f( const CCommand &args ) { OV_OpenMenuRoute( "shop" ); }
static void OV_MenuShop_f( const CCommand &args ) { OV_OpenMenuRoute( "shop" ); }
static void OV_MenuSettings_f( const CCommand &args ) { OV_OpenMenuRoute( "settings" ); }
static void OV_MenuSettings_f( const CCommand &args ) { OV_OpenMenuRoute( "settings" ); }
+static void OV_MenuConsole_f( const CCommand &args ) { OV_OpenMenuRoute( "console" ); }
+static void OV_MenuOptions_f( const CCommand &args ) { OV_OpenMenuRoute( "options" ); }
static ConCommand ov_ui_cmd(
static ConCommand ov_ui_cmd(
"ov_ui",
"ov_ui",
@@ -470,6 +761,37 @@ static ConCommand ov_menu_settings_route_cmd(
@@ -470,6 +761,37 @@ static ConCommand ov_menu_settings_route_cmd(
OV_MenuSettings_f,
OV_MenuSettings_f,
"Open the OpenVibe settings route.",
"Open the OpenVibe settings route.",
FCVAR_CLIENTDLL );
FCVAR_CLIENTDLL );
+
+static ConCommand ov_menu_console_cmd(
+ "ov_menu_console",
+ OV_MenuConsole_f,
+ "Open the OpenVibe console route.",
+ FCVAR_CLIENTDLL );
+
+static ConCommand ov_menu_options_cmd(
+ "ov_menu_options",
+ OV_MenuOptions_f,
+ "Open the OpenVibe options route.",
+ FCVAR_CLIENTDLL );
+
+// The OpenVibe console replaces the stock Source console: bind your console
+// key to ov_console (cfg/openvibe_client_default.cfg does this) and it
+// toggles the HTML console route.
+static void OV_ConsoleToggle_f( const CCommand &args )
+{
+ if ( s_pOpenVibeMenu && s_pOpenVibeMenu->IsVisible() )
+ {
+ s_pOpenVibeMenu->CloseMenu();
+ return;
+ }
+ OV_OpenMenuRoute( "console" );
+}
+
+static ConCommand ov_console_cmd(
+ "ov_console",
+ OV_ConsoleToggle_f,
+ "Toggle the OpenVibe HTML console (replacement for the stock console).",
+ FCVAR_CLIENTDLL );
// OPENVIBE_UNIFIED_UI_ROUTE_COMMANDS_END
// OPENVIBE_UNIFIED_UI_ROUTE_COMMANDS_END
// OPENVIBE_STEAM_AUTH_BRIDGE_BEGIN
// OPENVIBE_STEAM_AUTH_BRIDGE_BEGIN
@@ -18,6 +18,14 @@ static ConVar ov_js_enabled(
@@ -18,6 +18,14 @@ static ConVar ov_js_enabled(
FCVAR_GAMEDLL,
FCVAR_GAMEDLL,
"Enable OpenVibe JavaScript runtime. Windows server CI uses a stub runtime." );
"Enable OpenVibe JavaScript runtime. Windows server CI uses a stub runtime." );
+// GMod's sv_allowcslua equivalent: gates js_run_cl / js_openscript_cl on
+// clients. Replicated so the client bridge can read it. Default 1 for dev.
+static ConVar sv_allowcsjs(
+ "sv_allowcsjs",
+ "1",
+ FCVAR_REPLICATED | FCVAR_NOTIFY,
+ "Allow clients to run their own JavaScript (js_run_cl / js_openscript_cl)." );
+
void OpenVibeJS_ServerInit()
void OpenVibeJS_ServerInit()
{
{
Msg("[OV JS] Windows server CI stub active; runtime disabled, mode=%s\n", ov_mode.GetString());
Msg("[OV JS] Windows server CI stub active; runtime disabled, mode=%s\n", ov_mode.GetString());
@@ -81,6 +89,42 @@ static ConCommand ov_js_cmd(
@@ -81,6 +89,42 @@ static ConCommand ov_js_cmd(
FCVAR_GAMEDLL
FCVAR_GAMEDLL
);
);
+static void OV_JSRun_f(const CCommand &args)
+{
+ Msg("[OV JS] Windows server CI stub: js_run ignored\n");
+}
+
+static ConCommand js_run(
+ "js_run",
+ OV_JSRun_f,
+ "Run JavaScript in the server realm: js_run <code>.",
+ FCVAR_GAMEDLL
+);
+
+static void OV_JSOpenScript_f(const CCommand &args)
+{
+ Msg("[OV JS] Windows server CI stub: js_openscript ignored\n");
+}
+
+static ConCommand js_openscript(
+ "js_openscript",
+ OV_JSOpenScript_f,
+ "Run a script file in the server realm: js_openscript <path relative to js/>.",
+ FCVAR_GAMEDLL
+);
+
+static void OV_Npm_f(const CCommand &args)
+{
+ Warning("ov_npm requires ov_js_backend node\n");
+}
+
+static ConCommand ov_npm(
+ "ov_npm",
+ OV_Npm_f,
+ "Run npm in the mod js/ tree via the Node runtime: ov_npm <install|update|...> [args].",
+ FCVAR_GAMEDLL
+);
+
void OpenVibeJS_Server_RoundStart(int roundNumber)
void OpenVibeJS_Server_RoundStart(int roundNumber)
{
{
Msg("[OV JS] Windows server CI stub: RoundStart round=%d\n", roundNumber);
Msg("[OV JS] Windows server CI stub: RoundStart round=%d\n", roundNumber);
@@ -151,6 +195,12 @@ static ConVar ov_js_node_port(
@@ -151,6 +195,12 @@ static ConVar ov_js_node_port(
"ov_js_node_port", "41999", FCVAR_GAMEDLL,
"ov_js_node_port", "41999", FCVAR_GAMEDLL,
"TCP port of the OpenVibe server Node.js runtime host." );
"TCP port of the OpenVibe server Node.js runtime host." );
+// GMod's sv_allowcslua equivalent: gates js_run_cl / js_openscript_cl on
+// clients. Replicated so the client bridge can read it. Default 1 for dev.
+static ConVar sv_allowcsjs(
+ "sv_allowcsjs", "1", FCVAR_REPLICATED | FCVAR_NOTIFY,
+ "Allow clients to run their own JavaScript (js_run_cl / js_openscript_cl)." );
+
static COpenVibeJSRuntime g_OVServerJS;
static COpenVibeJSRuntime g_OVServerJS;
static bool g_OVServerJSStarted = false;
static bool g_OVServerJSStarted = false;
@@ -218,6 +268,10 @@ static void OVServer_OnLine( const char *line )
@@ -218,6 +268,10 @@ static void OVServer_OnLine( const char *line )
if ( p ) { engine->ClientCommand( p->edict(), "%s", cmd ); }
if ( p ) { engine->ClientCommand( p->edict(), "%s", cmd ); }
}
}
}
}
+ else if ( !Q_strcmp( t, "eval-result" ) )
+ {
+ // js_run result echoed back by the runtime — already logged there.
+ }
else if ( !Q_strcmp( t, "net" ) )
else if ( !Q_strcmp( t, "net" ) )
{
{
// server -> client net: OVNet usermessage. ids: CSV of userIds, -1 = all.
// server -> client net: OVNet usermessage. ids: CSV of userIds, -1 = all.
@@ -252,6 +306,22 @@ static void OVServer_SendHello()
@@ -252,6 +306,22 @@ static void OVServer_SendHello()
g_ServerIPC.SendLine( hello );
g_ServerIPC.SendLine( hello );
}
}
+// Append a {"__player":true,"userId":N} marker (or null) to an outbound
+// {"t":"event"} line — ov-runtime.js onGameMessage wraps these back into
+// Player objects before firing the hook.
+static void OVServer_AppendPlayerMarker( char *out, int cap, CHL2MP_Player *player )
+{
+ if ( !player )
+ {
+ Q_strncat( out, "null", cap, COPY_ALL_CHARACTERS );
+ return;
+ }
+
+ char marker[64];
+ Q_snprintf( marker, sizeof( marker ), "{\"__player\":true,\"userId\":%d}", player->GetUserID() );
+ Q_strncat( out, marker, cap, COPY_ALL_CHARACTERS );
+}
+
static bool OpenVibeJS_IsRunning()
static bool OpenVibeJS_IsRunning()
{
{
return ov_js_enabled.GetBool() && g_OVServerJS.Context() != nullptr;
return ov_js_enabled.GetBool() && g_OVServerJS.Context() != nullptr;
@@ -355,6 +425,23 @@ void OpenVibeJS_Server_PlayerInitialSpawn( CHL2MP_Player *player )
@@ -355,6 +425,23 @@ void OpenVibeJS_Server_PlayerInitialSpawn( CHL2MP_Player *player )
{
{
OpenVibeJS_EnsureStarted();
OpenVibeJS_EnsureStarted();
+ if ( OVServer_UseNode() )
+ {
+ if ( !player || !g_ServerIPC.IsConnected() )
+ return;
+ const char *netid = engine->GetPlayerNetworkIDString( player->edict() );
+ char out[512];
+ Q_snprintf( out, sizeof( out ), "{\"t\":\"player_connect\",\"userId\":%d,\"name\":\"", player->GetUserID() );
+ OVJSONAppendEsc( out, sizeof( out ), player->GetPlayerName() );
+ Q_strncat( out, "\",\"steamId\":\"", sizeof( out ), COPY_ALL_CHARACTERS );
+ OVJSONAppendEsc( out, sizeof( out ), netid ? netid : "" );
+ char tail[48];
+ Q_snprintf( tail, sizeof( tail ), "\",\"entIndex\":%d}", player->entindex() );
+ Q_strncat( out, tail, sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+ return;
+ }
+
if ( !OpenVibeJS_IsRunning() || !player )
if ( !OpenVibeJS_IsRunning() || !player )
return;
return;
@@ -371,6 +458,17 @@ void OpenVibeJS_Server_PlayerSpawn( CHL2MP_Player *player )
@@ -371,6 +458,17 @@ void OpenVibeJS_Server_PlayerSpawn( CHL2MP_Player *player )
{
{
OpenVibeJS_EnsureStarted();
OpenVibeJS_EnsureStarted();
+ if ( OVServer_UseNode() )
+ {
+ if ( !player || !g_ServerIPC.IsConnected() )
+ return;
+ char out[256] = "{\"t\":\"event\",\"name\":\"PlayerSpawn\",\"args\":[";
+ OVServer_AppendPlayerMarker( out, sizeof( out ), player );
+ Q_strncat( out, "]}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+ return;
+ }
+
if ( !OpenVibeJS_IsRunning() || !player )
if ( !OpenVibeJS_IsRunning() || !player )
return;
return;
@@ -387,6 +485,21 @@ void OpenVibeJS_Server_PlayerDeath( CHL2MP_Player *victim, CBaseEntity *attacker
@@ -387,6 +485,21 @@ void OpenVibeJS_Server_PlayerDeath( CHL2MP_Player *victim, CBaseEntity *attacker
{
{
OpenVibeJS_EnsureStarted();
OpenVibeJS_EnsureStarted();
+ if ( OVServer_UseNode() )
+ {
+ if ( !victim || !g_ServerIPC.IsConnected() )
+ return;
+ CHL2MP_Player *attackerPlayer = ( attacker && attacker->IsPlayer() )
+ ? ToHL2MPPlayer( static_cast<CBasePlayer *>( attacker ) ) : NULL;
+ char out[256] = "{\"t\":\"event\",\"name\":\"PlayerDeath\",\"args\":[";
+ OVServer_AppendPlayerMarker( out, sizeof( out ), victim );
+ Q_strncat( out, ",", sizeof( out ), COPY_ALL_CHARACTERS );
+ OVServer_AppendPlayerMarker( out, sizeof( out ), attackerPlayer );
+ Q_strncat( out, "]}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+ return;
+ }
+
if ( !OpenVibeJS_IsRunning() || !victim )
if ( !OpenVibeJS_IsRunning() || !victim )
return;
return;
@@ -409,6 +522,16 @@ void OpenVibeJS_Server_PlayerDisconnected( CHL2MP_Player *player )
@@ -409,6 +522,16 @@ void OpenVibeJS_Server_PlayerDisconnected( CHL2MP_Player *player )
{
{
OpenVibeJS_EnsureStarted();
OpenVibeJS_EnsureStarted();
+ if ( OVServer_UseNode() )
+ {
+ if ( !player || !g_ServerIPC.IsConnected() )
+ return;
+ char out[64];
+ Q_snprintf( out, sizeof( out ), "{\"t\":\"player_disconnect\",\"userId\":%d}", player->GetUserID() );
+ g_ServerIPC.SendLine( out );
+ return;
+ }
+
if ( !OpenVibeJS_IsRunning() || !player )
if ( !OpenVibeJS_IsRunning() || !player )
return;
return;
@@ -532,6 +655,164 @@ static ConCommand ov_js_cmd(
@@ -532,6 +655,164 @@ static ConCommand ov_js_cmd(
FCVAR_GAMEDLL
FCVAR_GAMEDLL
);
);
+// ---------------------------------------------------------------------------
+// GModJS script commands — js_run / js_openscript / ov_npm (server realm).
+// Embedded backend evals in-process; node backend forwards over IPC.
+// Results are echoed to the invoking client's console (the HTML GUI console
+// mirrors the client console through the spew tap, so this is what makes
+// js_run output visible in-game).
+// ---------------------------------------------------------------------------
+static void OV_JSEchoToCaller( const char *pszLine )
+{
+ if ( !pszLine || !pszLine[0] )
+ return;
+
+ CBasePlayer *pPlayer = UTIL_GetCommandClient();
+ if ( pPlayer )
+ {
+ char szMsg[512];
+ Q_snprintf( szMsg, sizeof( szMsg ), "[OV JS] %s\n", pszLine );
+ ClientPrint( pPlayer, HUD_PRINTCONSOLE, szMsg );
+ }
+}
+
+static void OV_JSRun_f( const CCommand &args )
+{
+ if ( args.ArgC() < 2 )
+ {
+ Msg( "Usage: js_run <code>\n" );
+ return;
+ }
+
+ OpenVibeJS_EnsureStarted();
+
+ if ( OVServer_UseNode() )
+ {
+ if ( !g_ServerIPC.IsConnected() )
+ {
+ Warning( "[OV JS] node runtime not connected; js_run ignored\n" );
+ OV_JSEchoToCaller( "node runtime not connected; js_run ignored" );
+ return;
+ }
+ char out[4096] = "{\"t\":\"eval\",\"code\":\"";
+ OVJSONAppendEsc( out, sizeof( out ), args.ArgS() );
+ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+ OV_JSEchoToCaller( "sent to node runtime (result in runtime log / GUI console)" );
+ return;
+ }
+
+ if ( !OpenVibeJS_IsRunning() )
+ {
+ Warning( "[OV JS] runtime is not running\n" );
+ OV_JSEchoToCaller( "runtime is not running" );
+ return;
+ }
+
+ char szResult[480];
+ g_OVServerJS.RunStringResult( args.ArgS(), szResult, sizeof( szResult ) );
+ OV_JSEchoToCaller( szResult );
+}
+
+static ConCommand js_run(
+ "js_run",
+ OV_JSRun_f,
+ "Run JavaScript in the server realm: js_run <code>.",
+ FCVAR_GAMEDLL
+);
+
+static void OV_JSOpenScript_f( const CCommand &args )
+{
+ if ( args.ArgC() < 2 )
+ {
+ Msg( "Usage: js_openscript <path relative to js/>\n" );
+ return;
+ }
+
+ OpenVibeJS_EnsureStarted();
+
+ if ( OVServer_UseNode() )
+ {
+ if ( !g_ServerIPC.IsConnected() )
+ {
+ Warning( "[OV JS] node runtime not connected; js_openscript ignored\n" );
+ OV_JSEchoToCaller( "node runtime not connected; js_openscript ignored" );
+ return;
+ }
+ char out[1024] = "{\"t\":\"openscript\",\"path\":\"";
+ OVJSONAppendEsc( out, sizeof( out ), args[1] );
+ Q_strncat( out, "\"}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+ OV_JSEchoToCaller( "sent to node runtime" );
+ return;
+ }
+
+ if ( !OpenVibeJS_IsRunning() )
+ {
+ Warning( "[OV JS] runtime is not running\n" );
+ OV_JSEchoToCaller( "runtime is not running" );
+ return;
+ }
+
+ char code[1024] = "OVLoader.openScript(\"";
+ OVJSONAppendEsc( code, sizeof( code ), args[1] );
+ Q_strncat( code, "\")", sizeof( code ), COPY_ALL_CHARACTERS );
+ char szResult[480];
+ g_OVServerJS.RunStringResult( code, szResult, sizeof( szResult ) );
+ char szEcho[512];
+ Q_snprintf( szEcho, sizeof( szEcho ), "js_openscript %s -> %s", args[1], szResult );
+ OV_JSEchoToCaller( szEcho );
+}
+
+static ConCommand js_openscript(
+ "js_openscript",
+ OV_JSOpenScript_f,
+ "Run a script file in the server realm: js_openscript <path relative to js/>.",
+ FCVAR_GAMEDLL
+);
+
+static void OV_Npm_f( const CCommand &args )
+{
+ if ( args.ArgC() < 2 )
+ {
+ Msg( "Usage: ov_npm <install|update|uninstall|ls> [args]\n" );
+ return;
+ }
+
+ OpenVibeJS_EnsureStarted();
+
+ if ( !OVServer_UseNode() )
+ {
+ Warning( "ov_npm requires ov_js_backend node\n" );
+ return;
+ }
+
+ if ( !g_ServerIPC.IsConnected() )
+ {
+ Warning( "[OV JS] node runtime not connected; ov_npm ignored\n" );
+ return;
+ }
+
+ char out[1024] = "{\"t\":\"npm\",\"args\":[";
+ for ( int i = 1; i < args.ArgC(); ++i )
+ {
+ if ( i > 1 )
+ Q_strncat( out, ",", sizeof( out ), COPY_ALL_CHARACTERS );
+ Q_strncat( out, "\"", sizeof( out ), COPY_ALL_CHARACTERS );
+ OVJSONAppendEsc( out, sizeof( out ), args[i] );
+ Q_strncat( out, "\"", sizeof( out ), COPY_ALL_CHARACTERS );
+ }
+ Q_strncat( out, "]}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+}
+
+static ConCommand ov_npm(
+ "ov_npm",
+ OV_Npm_f,
+ "Run npm in the mod js/ tree via the Node runtime: ov_npm <install|update|...> [args].",
+ FCVAR_GAMEDLL
+);
+
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// net library — client->server transport.
// net library — client->server transport.
// The client forwards net messages as: ov_net <name> <payloadBase64>
// The client forwards net messages as: ov_net <name> <payloadBase64>
@@ -598,6 +879,17 @@ void OpenVibeJS_Server_RoundStart( int roundNumber )
@@ -598,6 +879,17 @@ void OpenVibeJS_Server_RoundStart( int roundNumber )
{
{
OpenVibeJS_EnsureStarted();
OpenVibeJS_EnsureStarted();
+ if ( OVServer_UseNode() )
+ {
+ if ( !g_ServerIPC.IsConnected() )
+ return;
+ char out[96];
+ Q_snprintf( out, sizeof( out ), "{\"t\":\"event\",\"name\":\"RoundStart\",\"args\":[%d]}", roundNumber );
+ g_ServerIPC.SendLine( out );
+ Msg( "[OV JS] RoundStart forwarded round=%d\n", roundNumber );
+ return;
+ }
+
if ( !OpenVibeJS_IsRunning() )
if ( !OpenVibeJS_IsRunning() )
return;
return;
@@ -614,6 +906,19 @@ void OpenVibeJS_Server_RoundEnd( int roundNumber, const char *reason )
@@ -614,6 +906,19 @@ void OpenVibeJS_Server_RoundEnd( int roundNumber, const char *reason )
{
{
OpenVibeJS_EnsureStarted();
OpenVibeJS_EnsureStarted();
+ if ( OVServer_UseNode() )
+ {
+ if ( !g_ServerIPC.IsConnected() )
+ return;
+ char out[256];
+ Q_snprintf( out, sizeof( out ), "{\"t\":\"event\",\"name\":\"RoundEnd\",\"args\":[%d,\"", roundNumber );
+ OVJSONAppendEsc( out, sizeof( out ), reason ? reason : "time" );
+ Q_strncat( out, "\"]}", sizeof( out ), COPY_ALL_CHARACTERS );
+ g_ServerIPC.SendLine( out );
+ Msg( "[OV JS] RoundEnd forwarded round=%d reason=%s\n", roundNumber, reason ? reason : "time" );
+ return;
+ }
+
if ( !OpenVibeJS_IsRunning() )
if ( !OpenVibeJS_IsRunning() )
return;
return;
@@ -10,6 +10,9 @@ void OVJS_RegisterNativeBindings(JSContext *ctx, COpenVibeJSRuntime *runtime) {}
@@ -10,6 +10,9 @@ void OVJS_RegisterNativeBindings(JSContext *ctx, COpenVibeJSRuntime *runtime) {}
#include "ov_js_bindings.h"
#include "ov_js_bindings.h"
#include "ov_js_runtime.h"
#include "ov_js_runtime.h"
#include "ov_js_player.h"
#include "ov_js_player.h"
+#ifdef GAME_DLL
+#include "ov_js_entity.h"
+#endif
#include "hl2mp_player.h"
#include "hl2mp_player.h"
#include "util.h"
#include "util.h"
#include "filesystem.h"
#include "filesystem.h"
@@ -348,6 +351,11 @@ static const JSCFunctionListEntry OVFuncs[] =
@@ -348,6 +351,11 @@ static const JSCFunctionListEntry OVFuncs[] =
JS_CFUNC_DEF("fileExists", 1, OVJS_fileExists),
JS_CFUNC_DEF("fileExists", 1, OVJS_fileExists),
JS_CFUNC_DEF("listDir", 2, OVJS_listDir),
JS_CFUNC_DEF("listDir", 2, OVJS_listDir),
JS_CFUNC_DEF("netEmit", 3, OVJS_netEmit),
JS_CFUNC_DEF("netEmit", 3, OVJS_netEmit),
+#ifdef GAME_DLL
+ // Entity natives (server realm only) — implemented in ov_js_entity.cpp.
+ JS_CFUNC_DEF("entCreate", 1, OVJS_entCreate),
+ JS_CFUNC_DEF("entCall", 3, OVJS_entCall),
+#endif
};
};
void OVJS_RegisterNativeBindings(JSContext *ctx, COpenVibeJSRuntime *runtime)
void OVJS_RegisterNativeBindings(JSContext *ctx, COpenVibeJSRuntime *runtime)
new file mode 100644
new file mode 100644
@@ -0,0 +1,507 @@
@@ -0,0 +1,507 @@
+#include "cbase.h"
+// OPENVIBE_WINDOWS_SERVER_QUICKJS_STUB
+#if defined(_WIN32) && defined(GAME_DLL) && !defined(OPENVIBE_WINDOWS_SERVER_REAL_QUICKJS)
+#include "ov_js_entity.h"
+
+#include "tier0/memdbgon.h"
+
+JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv) { return JS_NULL; }
+JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv) { return JS_UNDEFINED; }
+#else
+#include "ov_js_entity.h"
+#include "util.h"
+#include "baseanimating.h"
+#include "eventqueue.h"
+#include "gamestringpool.h"
+#include "vphysics_interface.h"
+
+#include "tier0/memdbgon.h"
+
+// ---------------------------------------------------------------------------
+// Entity natives — the C++ half of js/core/entity.js. The JS Entity wrapper
+// drives real engine entities through OV.entCreate/OV.entCall; anything the
+// dispatcher doesn't know returns undefined and the JS side degrades to its
+// pure-JS logical entity behavior.
+// ---------------------------------------------------------------------------
+
+// Engine classes JS is allowed to create. Scripted entity classes ("ov_*")
+// are also accepted; unknown ov_ classnames fail in CreateEntityByName and
+// entCreate returns null, which the JS side treats as "logical entity only".
+static const char *s_OVAllowedEntityClasses[] =
+{
+ "prop_physics",
+ "prop_physics_override",
+ "prop_dynamic",
+ "prop_dynamic_override",
+ "info_target",
+ "env_sprite",
+ "light_dynamic",
+};
+
+static bool OVJS_IsAllowedEntityClass(const char *className)
+{
+ if (!className || !className[0])
+ return false;
+
+ if (!Q_strnicmp(className, "ov_", 3))
+ return true;
+
+ for (int i = 0; i < ARRAYSIZE(s_OVAllowedEntityClasses); ++i)
+ {
+ if (!Q_stricmp(className, s_OVAllowedEntityClasses[i]))
+ return true;
+ }
+
+ return false;
+}
+
+// Model paths JS may set: mod-relative "models/*.mdl", no traversal.
+static bool OVJS_IsAllowedModelPath(const char *path)
+{
+ if (!path || Q_strnicmp(path, "models/", 7))
+ return false;
+
+ int len = Q_strlen(path);
+ if (len < 12 || Q_stricmp(path + len - 4, ".mdl"))
+ return false;
+
+ if (Q_strstr(path, ".."))
+ return false;
+
+ return true;
+}
+
+// ---- guarded reads from the JS args array ----
+static double OVJS_ArgNumber(JSContext *ctx, JSValueConst args, uint32 index, double defaultValue)
+{
+ if (!JS_IsObject(args))
+ return defaultValue;
+
+ JSValue v = JS_GetPropertyUint32(ctx, args, index);
+ double out = defaultValue;
+
+ if (!JS_IsUndefined(v) && !JS_IsException(v))
+ {
+ double d = 0.0;
+ if (JS_ToFloat64(ctx, &d, v) == 0)
+ out = d;
+ }
+
+ JS_FreeValue(ctx, v);
+ return out;
+}
+
+static int OVJS_ArgInt(JSContext *ctx, JSValueConst args, uint32 index, int defaultValue)
+{
+ return (int)OVJS_ArgNumber(ctx, args, index, (double)defaultValue);
+}
+
+static bool OVJS_ArgBool(JSContext *ctx, JSValueConst args, uint32 index, bool defaultValue)
+{
+ if (!JS_IsObject(args))
+ return defaultValue;
+
+ JSValue v = JS_GetPropertyUint32(ctx, args, index);
+ bool out = defaultValue;
+
+ if (!JS_IsUndefined(v) && !JS_IsException(v))
+ out = JS_ToBool(ctx, v) != 0;
+
+ JS_FreeValue(ctx, v);
+ return out;
+}
+
+static bool OVJS_ArgString(JSContext *ctx, JSValueConst args, uint32 index, char *out, int outSize)
+{
+ out[0] = '\0';
+
+ if (!JS_IsObject(args))
+ return false;
+
+ JSValue v = JS_GetPropertyUint32(ctx, args, index);
+ if (!JS_IsUndefined(v) && !JS_IsException(v))
+ {
+ const char *s = JS_ToCString(ctx, v);
+ if (s)
+ {
+ Q_strncpy(out, s, outSize);
+ JS_FreeCString(ctx, s);
+ }
+ }
+
+ JS_FreeValue(ctx, v);
+ return out[0] != '\0';
+}
+
+static JSValue OVJS_NewVectorObject(JSContext *ctx, const Vector &vec)
+{
+ JSValue obj = JS_NewObject(ctx);
+ JS_SetPropertyStr(ctx, obj, "x", JS_NewFloat64(ctx, vec.x));
+ JS_SetPropertyStr(ctx, obj, "y", JS_NewFloat64(ctx, vec.y));
+ JS_SetPropertyStr(ctx, obj, "z", JS_NewFloat64(ctx, vec.z));
+ return obj;
+}
+
+static JSValue OVJS_NewAngleObject(JSContext *ctx, const QAngle &ang)
+{
+ JSValue obj = JS_NewObject(ctx);
+ JS_SetPropertyStr(ctx, obj, "p", JS_NewFloat64(ctx, ang.x));
+ JS_SetPropertyStr(ctx, obj, "y", JS_NewFloat64(ctx, ang.y));
+ JS_SetPropertyStr(ctx, obj, "r", JS_NewFloat64(ctx, ang.z));
+ return obj;
+}
+
+static int OVJS_ClampColorComponent(int value)
+{
+ if (value < 0) return 0;
+ if (value > 255) return 255;
+ return value;
+}
+
+// OV.entCreate(className) -> {entIndex} | null
+JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv)
+{
+ if (argc < 1)
+ return JS_NULL;
+
+ const char *className = JS_ToCString(ctx, argv[0]);
+ if (!className)
+ return JS_NULL;
+
+ if (!OVJS_IsAllowedEntityClass(className))
+ {
+ Warning("[OV JS] OV.entCreate refused class: %s\n", className);
+ JS_FreeCString(ctx, className);
+ return JS_NULL;
+ }
+
+ CBaseEntity *ent = CreateEntityByName(className);
+ JS_FreeCString(ctx, className);
+
+ if (!ent)
+ return JS_NULL;
+
+ JSValue obj = JS_NewObject(ctx);
+ JS_SetPropertyStr(ctx, obj, "entIndex", JS_NewInt32(ctx, ent->entindex()));
+ return obj;
+}
+
+// The method dispatcher behind OV.entCall. Unknown methods return undefined so
+// the JS wrapper can degrade gracefully.
+static JSValue OVJS_EntDispatch(JSContext *ctx, CBaseEntity *ent, const char *method, JSValueConst args)
+{
+ // ---- lifecycle ----
+ if (!Q_strcmp(method, "spawn"))
+ {
+ DispatchSpawn(ent);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "activate"))
+ {
+ ent->Activate();
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "remove"))
+ {
+ UTIL_Remove(ent);
+ return JS_UNDEFINED;
+ }
+
+ // ---- appearance ----
+ if (!Q_strcmp(method, "setModel"))
+ {
+ char model[256];
+ if (!OVJS_ArgString(ctx, args, 0, model, sizeof(model)))
+ return JS_UNDEFINED;
+
+ if (!OVJS_IsAllowedModelPath(model))
+ {
+ Warning("[OV JS] entCall setModel refused model: %s\n", model);
+ return JS_UNDEFINED;
+ }
+
+ CBaseEntity::PrecacheModel(model);
+ ent->SetModel(model);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setModelScale"))
+ {
+ CBaseAnimating *anim = ent->GetBaseAnimating();
+ if (anim)
+ anim->SetModelScale((float)OVJS_ArgNumber(ctx, args, 0, 1.0));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setColor"))
+ {
+ int r = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 0, 255));
+ int g = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 1, 255));
+ int b = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 2, 255));
+ int a = OVJS_ClampColorComponent(OVJS_ArgInt(ctx, args, 3, 255));
+ ent->SetRenderColor(r, g, b, a);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setMaterial"))
+ {
+ char material[256];
+ if (OVJS_ArgString(ctx, args, 0, material, sizeof(material)))
+ ent->KeyValue("material", material);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setRenderMode"))
+ {
+ ent->SetRenderMode((RenderMode_t)OVJS_ArgInt(ctx, args, 0, kRenderNormal));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setNoDraw"))
+ {
+ if (OVJS_ArgBool(ctx, args, 0, true))
+ ent->AddEffects(EF_NODRAW);
+ else
+ ent->RemoveEffects(EF_NODRAW);
+ return JS_UNDEFINED;
+ }
+
+ // ---- transform ----
+ if (!Q_strcmp(method, "getPos"))
+ return OVJS_NewVectorObject(ctx, ent->GetAbsOrigin());
+
+ if (!Q_strcmp(method, "setPos"))
+ {
+ Vector pos(
+ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));
+ ent->Teleport(&pos, NULL, NULL);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "getAngles"))
+ return OVJS_NewAngleObject(ctx, ent->GetAbsAngles());
+
+ if (!Q_strcmp(method, "setAngles"))
+ {
+ QAngle ang(
+ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));
+ ent->Teleport(NULL, &ang, NULL);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "getVelocity"))
+ return OVJS_NewVectorObject(ctx, ent->GetAbsVelocity());
+
+ if (!Q_strcmp(method, "setVelocity"))
+ {
+ Vector vel(
+ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));
+
+ IPhysicsObject *phys = ent->VPhysicsGetObject();
+ if (phys)
+ phys->SetVelocity(&vel, NULL);
+ else
+ ent->SetAbsVelocity(vel);
+ return JS_UNDEFINED;
+ }
+
+ // ---- health / damage ----
+ if (!Q_strcmp(method, "health"))
+ return JS_NewInt32(ctx, ent->GetHealth());
+
+ if (!Q_strcmp(method, "setHealth"))
+ {
+ ent->SetHealth(OVJS_ArgInt(ctx, args, 0, 0));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setMaxHealth"))
+ {
+ ent->SetMaxHealth(OVJS_ArgInt(ctx, args, 0, 0));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "takeDamage"))
+ {
+ float damage = (float)OVJS_ArgNumber(ctx, args, 0, 0.0);
+ if (damage < 0.0f)
+ return JS_UNDEFINED;
+
+ int attackerIndex = OVJS_ArgInt(ctx, args, 1, 0);
+ CBaseEntity *attacker = attackerIndex > 0 ? UTIL_EntityByIndex(attackerIndex) : NULL;
+ if (!attacker)
+ attacker = ent;
+
+ CTakeDamageInfo info(attacker, attacker, damage, DMG_GENERIC);
+ ent->TakeDamage(info);
+ return JS_UNDEFINED;
+ }
+
+ // ---- movement / collision ----
+ if (!Q_strcmp(method, "setMoveType"))
+ {
+ ent->SetMoveType((MoveType_t)OVJS_ArgInt(ctx, args, 0, MOVETYPE_NONE));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setSolid"))
+ {
+ ent->SetSolid((SolidType_t)OVJS_ArgInt(ctx, args, 0, SOLID_NONE));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setCollisionGroup"))
+ {
+ ent->SetCollisionGroup(OVJS_ArgInt(ctx, args, 0, COLLISION_GROUP_NONE));
+ return JS_UNDEFINED;
+ }
+
+ // ---- keyvalues / IO ----
+ if (!Q_strcmp(method, "setKeyValue"))
+ {
+ char key[128], value[512];
+ if (OVJS_ArgString(ctx, args, 0, key, sizeof(key)))
+ {
+ OVJS_ArgString(ctx, args, 1, value, sizeof(value));
+ ent->KeyValue(key, value);
+ }
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "fire"))
+ {
+ char input[128], param[256];
+ if (!OVJS_ArgString(ctx, args, 0, input, sizeof(input)))
+ return JS_UNDEFINED;
+
+ OVJS_ArgString(ctx, args, 1, param, sizeof(param));
+ float delay = (float)OVJS_ArgNumber(ctx, args, 2, 0.0);
+
+ variant_t value;
+ if (param[0])
+ value.SetString(AllocPooledString(param));
+
+ g_EventQueue.AddEvent(ent, input, value, delay, NULL, NULL);
+ return JS_UNDEFINED;
+ }
+
+ // ---- physics ----
+ if (!Q_strcmp(method, "physicsInit"))
+ {
+ int solidType = OVJS_ArgInt(ctx, args, 0, SOLID_VPHYSICS);
+ if (solidType == SOLID_VPHYSICS)
+ return ent->VPhysicsInitNormal(SOLID_VPHYSICS, 0, false) ? JS_TRUE : JS_FALSE;
+
+ ent->SetSolid((SolidType_t)solidType);
+ return JS_TRUE;
+ }
+
+ if (!Q_strcmp(method, "physWake"))
+ {
+ IPhysicsObject *phys = ent->VPhysicsGetObject();
+ if (phys)
+ phys->Wake();
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "hasPhysics"))
+ return ent->VPhysicsGetObject() ? JS_TRUE : JS_FALSE;
+
+ if (!Q_strcmp(method, "setMass"))
+ {
+ IPhysicsObject *phys = ent->VPhysicsGetObject();
+ float mass = (float)OVJS_ArgNumber(ctx, args, 0, 1.0);
+ if (phys && mass > 0.0f)
+ phys->SetMass(mass);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "enableMotion"))
+ {
+ IPhysicsObject *phys = ent->VPhysicsGetObject();
+ if (phys)
+ phys->EnableMotion(OVJS_ArgBool(ctx, args, 0, true));
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "applyForceCenter"))
+ {
+ IPhysicsObject *phys = ent->VPhysicsGetObject();
+ if (phys)
+ {
+ Vector force(
+ (float)OVJS_ArgNumber(ctx, args, 0, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 1, 0.0),
+ (float)OVJS_ArgNumber(ctx, args, 2, 0.0));
+ phys->ApplyForceCenter(force);
+ }
+ return JS_UNDEFINED;
+ }
+
+ // ---- hierarchy ----
+ if (!Q_strcmp(method, "setParent"))
+ {
+ int parentIndex = OVJS_ArgInt(ctx, args, 0, 0);
+ CBaseEntity *parent = parentIndex > 0 ? UTIL_EntityByIndex(parentIndex) : NULL;
+ ent->SetParent(parent);
+ return JS_UNDEFINED;
+ }
+
+ if (!Q_strcmp(method, "setOwner"))
+ {
+ int ownerIndex = OVJS_ArgInt(ctx, args, 0, 0);
+ CBaseEntity *owner = ownerIndex > 0 ? UTIL_EntityByIndex(ownerIndex) : NULL;
+ ent->SetOwnerEntity(owner);
+ return JS_UNDEFINED;
+ }
+
+ // ---- sound ----
+ if (!Q_strcmp(method, "emitSound"))
+ {
+ char sound[256];
+ if (OVJS_ArgString(ctx, args, 0, sound, sizeof(sound)))
+ {
+ CBaseEntity::PrecacheScriptSound(sound);
+ ent->EmitSound(sound);
+ }
+ return JS_UNDEFINED;
+ }
+
+ // Unknown method: undefined, JS side treats it gracefully.
+ return JS_UNDEFINED;
+}
+
+// OV.entCall(entIndex, method, argsArray) -> method-dependent | undefined
+JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv)
+{
+ if (argc < 2)
+ return JS_UNDEFINED;
+
+ int32 entIndex = 0;
+ JS_ToInt32(ctx, &entIndex, argv[0]);
+
+ // World (0) and out-of-range indices are not scriptable through this path.
+ CBaseEntity *ent = (entIndex > 0 && entIndex < NUM_ENT_ENTRIES) ? UTIL_EntityByIndex(entIndex) : NULL;
+ if (!ent || ent->IsMarkedForDeletion())
+ return JS_UNDEFINED;
+
+ const char *method = JS_ToCString(ctx, argv[1]);
+ if (!method)
+ return JS_UNDEFINED;
+
+ JSValueConst args = (argc >= 3) ? argv[2] : JS_UNDEFINED;
+
+ JSValue result = OVJS_EntDispatch(ctx, ent, method, args);
+
+ JS_FreeCString(ctx, method);
+ return result;
+}
+#endif
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+#pragma once
+
+// OPENVIBE_WINDOWS_SERVER_QUICKJS_STUB
+#if defined(_WIN32) && defined(GAME_DLL) && !defined(OPENVIBE_WINDOWS_SERVER_REAL_QUICKJS)
+
+#include "openvibe/ov_js_runtime.h"
+JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);
+JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);
+#else
+#include "openvibe/third_party/quickjs/quickjs.h"
+
+// Entity native bindings (server realm only). Registered on the OV global by
+// OVJS_RegisterNativeBindings:
+// OV.entCreate(className) -> {entIndex} | null
+// OV.entCall(entIndex, method, args[]) -> method-dependent | undefined
+JSValue OVJS_entCreate(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);
+JSValue OVJS_entCall(JSContext *ctx, JSValueConst thisVal, int argc, JSValueConst *argv);
+#endif
@@ -19,6 +19,12 @@ bool COpenVibeJSRuntime::Init(bool bServerRealm, const char *pszMode)
@@ -19,6 +19,12 @@ bool COpenVibeJSRuntime::Init(bool bServerRealm, const char *pszMode)
void COpenVibeJSRuntime::Shutdown() {}
void COpenVibeJSRuntime::Shutdown() {}
bool COpenVibeJSRuntime::LoadFile(const char *pszPath) { return false; }
bool COpenVibeJSRuntime::LoadFile(const char *pszPath) { return false; }
bool COpenVibeJSRuntime::Eval(const char *pszCode, const char *pszFilename) { return false; }
bool COpenVibeJSRuntime::Eval(const char *pszCode, const char *pszFilename) { return false; }
+bool COpenVibeJSRuntime::RunString(const char *pszCode) { return false; }
+bool COpenVibeJSRuntime::RunStringResult(const char *pszCode, char *pszOut, int nOutLen)
+{
+ if (pszOut && nOutLen > 0) pszOut[0] = '\0';
+ return false;
+}
JSValue COpenVibeJSRuntime::CallHookRaw(const char *pszHookName, int argc, JSValueConst *argv) { return JS_UNDEFINED; }
JSValue COpenVibeJSRuntime::CallHookRaw(const char *pszHookName, int argc, JSValueConst *argv) { return JS_UNDEFINED; }
void COpenVibeJSRuntime::CallHookVoid(const char *pszHookName, int argc, JSValueConst *argv) {}
void COpenVibeJSRuntime::CallHookVoid(const char *pszHookName, int argc, JSValueConst *argv) {}
bool COpenVibeJSRuntime::CallHookBool(const char *pszHookName, bool *pOut, int argc, JSValueConst *argv)
bool COpenVibeJSRuntime::CallHookBool(const char *pszHookName, bool *pOut, int argc, JSValueConst *argv)
@@ -117,6 +123,71 @@ bool COpenVibeJSRuntime::Eval(const char *pszCode, const char *pszFilename)
@@ -117,6 +123,71 @@ bool COpenVibeJSRuntime::Eval(const char *pszCode, const char *pszFilename)
return true;
return true;
}
}
+// js_run backing: eval a console-supplied snippet and log its result/error.
+bool COpenVibeJSRuntime::RunString(const char *pszCode)
+{
+ return RunStringResult(pszCode, NULL, 0);
+}
+
+// Same, but also captures the stringified result / exception text so the
+// ConCommand can echo it back to the invoking client's console.
+bool COpenVibeJSRuntime::RunStringResult(const char *pszCode, char *pszOut, int nOutLen)
+{
+ if (pszOut && nOutLen > 0)
+ pszOut[0] = '\0';
+
+ if (!m_pCtx || !pszCode)
+ {
+ if (pszOut && nOutLen > 0)
+ Q_strncpy(pszOut, "runtime is not running", nOutLen);
+ return false;
+ }
+
+ JSValue result = JS_Eval(
+ m_pCtx,
+ pszCode,
+ Q_strlen(pszCode),
+ "<js_run>",
+ JS_EVAL_TYPE_GLOBAL
+ );
+
+ if (JS_IsException(result))
+ {
+ JSValue exc = JS_GetException(m_pCtx);
+ const char *pszExc = JS_ToCString(m_pCtx, exc);
+ Warning("[OV JS] <js_run> exception: %s\n", pszExc ? pszExc : "<unprintable>");
+ if (pszOut && nOutLen > 0)
+ {
+ Q_strncpy(pszOut, "ERROR: ", nOutLen);
+ Q_strncat(pszOut, pszExc ? pszExc : "<unprintable>", nOutLen, COPY_ALL_CHARACTERS);
+ }
+ if (pszExc)
+ JS_FreeCString(m_pCtx, pszExc);
+ JS_FreeValue(m_pCtx, exc);
+ JS_FreeValue(m_pCtx, result);
+ return false;
+ }
+
+ if (JS_IsUndefined(result))
+ {
+ Msg("[OV JS] undefined\n");
+ if (pszOut && nOutLen > 0)
+ Q_strncpy(pszOut, "undefined", nOutLen);
+ }
+ else
+ {
+ const char *pszResult = JS_ToCString(m_pCtx, result);
+ Msg("[OV JS] %s\n", pszResult ? pszResult : "<unprintable>");
+ if (pszOut && nOutLen > 0)
+ Q_strncpy(pszOut, pszResult ? pszResult : "<unprintable>", nOutLen);
+ if (pszResult)
+ JS_FreeCString(m_pCtx, pszResult);
+ }
+
+ JS_FreeValue(m_pCtx, result);
+ return true;
+}
+
bool COpenVibeJSRuntime::LoadFile(const char *pszPath)
bool COpenVibeJSRuntime::LoadFile(const char *pszPath)
{
{
FileHandle_t file = filesystem->Open(pszPath, "rb", "MOD");
FileHandle_t file = filesystem->Open(pszPath, "rb", "MOD");
@@ -172,7 +243,14 @@ bool COpenVibeJSRuntime::LoadGamemode()
@@ -172,7 +243,14 @@ bool COpenVibeJSRuntime::LoadGamemode()
char path[256];
char path[256];
Q_snprintf(path, sizeof(path), "js/gamemodes/%s/%s.js", m_szMode, m_bServerRealm ? "server" : "client");
Q_snprintf(path, sizeof(path), "js/gamemodes/%s/%s.js", m_szMode, m_bServerRealm ? "server" : "client");
- return LoadFile(path);
+ bool ok = LoadFile(path);
+
+ // GModJS loader: run autorun/entities/addons under the embedded backend
+ // too. Non-fatal — Eval prints the exception and a stripped js/ tree
+ // simply has no OVLoader.
+ Eval("globalThis.OVLoader && OVLoader.loadAll && OVLoader.loadAll({mode: OV.getMode()})", "<ov-loader>");
+
+ return ok;
}
}
JSValue COpenVibeJSRuntime::CallHookRaw(const char *pszHookName, int argc, JSValueConst *argv)
JSValue COpenVibeJSRuntime::CallHookRaw(const char *pszHookName, int argc, JSValueConst *argv)
@@ -24,6 +24,10 @@ public:
@@ -24,6 +24,10 @@ public:
bool LoadFile(const char *pszPath);
bool LoadFile(const char *pszPath);
bool Eval(const char *pszCode, const char *pszFilename);
bool Eval(const char *pszCode, const char *pszFilename);
+ bool RunString(const char *pszCode);
+ // Like RunString, but also copies the stringified result (or the exception
+ // text) into pszOut so callers can echo it to the invoking client.
+ bool RunStringResult(const char *pszCode, char *pszOut, int nOutLen);
JSValue CallHookRaw(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
JSValue CallHookRaw(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
void CallHookVoid(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
void CallHookVoid(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
@@ -51,6 +55,10 @@ public:
@@ -51,6 +55,10 @@ public:
bool LoadFile(const char *pszPath);
bool LoadFile(const char *pszPath);
bool Eval(const char *pszCode, const char *pszFilename);
bool Eval(const char *pszCode, const char *pszFilename);
+ bool RunString(const char *pszCode);
+ // Like RunString, but also copies the stringified result (or the exception
+ // text) into pszOut so callers can echo it to the invoking client.
+ bool RunStringResult(const char *pszCode, char *pszOut, int nOutLen);
JSValue CallHookRaw(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
JSValue CallHookRaw(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
void CallHookVoid(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
void CallHookVoid(const char *pszHookName, int argc = 0, JSValueConst *argv = nullptr);
@@ -54,6 +54,10 @@ copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.h" \
@@ -54,6 +54,10 @@ copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.h" \
"$SDK/src/game/shared/openvibe/ov_js_player.h"
"$SDK/src/game/shared/openvibe/ov_js_player.h"
copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.cpp" \
copy_file "$ROOT/sdk/openvibe/shared/ov_js_player.cpp" \
"$SDK/src/game/shared/openvibe/ov_js_player.cpp"
"$SDK/src/game/shared/openvibe/ov_js_player.cpp"
+copy_file "$ROOT/sdk/openvibe/shared/ov_js_entity.h" \
+ "$SDK/src/game/shared/openvibe/ov_js_entity.h"
+copy_file "$ROOT/sdk/openvibe/shared/ov_js_entity.cpp" \
+ "$SDK/src/game/shared/openvibe/ov_js_entity.cpp"
copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.h" \
copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.h" \
"$SDK/src/game/shared/openvibe/ovjs_core.h"
"$SDK/src/game/shared/openvibe/ovjs_core.h"
copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.c" \
copy_file "$ROOT/sdk/openvibe/shared/ovjs_core.c" \
@@ -108,6 +112,7 @@ perl -0pi -e '
@@ -108,6 +112,7 @@ perl -0pi -e '
s/^.*openvibe\\ov_js_runtime\.cpp.*\n//mg;
s/^.*openvibe\\ov_js_runtime\.cpp.*\n//mg;
s/^.*openvibe\\ov_js_bindings\.cpp.*\n//mg;
s/^.*openvibe\\ov_js_bindings\.cpp.*\n//mg;
s/^.*openvibe\\ov_js_player\.cpp.*\n//mg;
s/^.*openvibe\\ov_js_player\.cpp.*\n//mg;
+ s/^.*openvibe\\ov_js_entity\.cpp.*\n//mg;
s/^.*openvibe\\ov_ipc\.cpp.*\n//mg;
s/^.*openvibe\\ov_ipc\.cpp.*\n//mg;
s/^.*quickjs\\quickjs\.c.*\n//mg;
s/^.*quickjs\\quickjs\.c.*\n//mg;
s/^.*quickjs\\libregexp\.c.*\n//mg;
s/^.*quickjs\\libregexp\.c.*\n//mg;
@@ -116,7 +121,7 @@ perl -0pi -e '
@@ -116,7 +121,7 @@ perl -0pi -e '
s/^.*quickjs\\dtoa\.c.*\n//mg;
s/^.*quickjs\\dtoa\.c.*\n//mg;
s/^.*quickjs\\libbf\.c.*\n//mg;
s/^.*quickjs\\libbf\.c.*\n//mg;
s/^.*libquickjs_openvibe(?:\.a)?".*\n//mg;
s/^.*libquickjs_openvibe(?:\.a)?".*\n//mg;
- s/(\$File\s+"hl2mp\\hl2mp_player\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_server.cpp"\n\t\t\t\$File\t"hl2mp\\openvibe_js_server.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_runtime.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_bindings.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_player.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_ipc.cpp"\n\t\t\t\$Lib\t"..\\shared\\openvibe\\third_party\\quickjs\\build\\libquickjs_openvibe"\n/s;
+ s/(\$File\s+"hl2mp\\hl2mp_player\.h"\n)/$1\t\t\t\$File\t"hl2mp\\openvibe_server.cpp"\n\t\t\t\$File\t"hl2mp\\openvibe_js_server.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_runtime.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_bindings.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_player.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_js_entity.cpp"\n\t\t\t\$File\t"..\\shared\\openvibe\\ov_ipc.cpp"\n\t\t\t\$Lib\t"..\\shared\\openvibe\\third_party\\quickjs\\build\\libquickjs_openvibe"\n/s;
' "$SERVER_VPC"
' "$SERVER_VPC"
echo "[openvibe-sdk] patched server_hl2mp.vpc"
echo "[openvibe-sdk] patched server_hl2mp.vpc"
@@ -35,9 +35,17 @@ if [[ "${1:-}" != "" && "${2:-}" != "" ]]; then
@@ -35,9 +35,17 @@ if [[ "${1:-}" != "" && "${2:-}" != "" ]]; then
CONNECT_ARGS=(+connect "$1:$2")
CONNECT_ARGS=(+connect "$1:$2")
fi
fi
+# The OpenVibe HTML console replaces the stock Source console; pass
+# OPENVIBE_STOCK_CONSOLE=1 to get the engine console back for debugging.
+CONSOLE_ARGS=()
+if [[ "${OPENVIBE_STOCK_CONSOLE:-0}" == "1" ]]; then
+ CONSOLE_ARGS=(-console)
+fi
+
exec "$HL2_LINUX" \
exec "$HL2_LINUX" \
-game "$GAME_DIR" \
-game "$GAME_DIR" \
- -console -dev -novid -sw -w 1280 -h 720 \
+ "${CONSOLE_ARGS[@]}" -dev -novid -sw -w 1280 -h 720 \
-port 27115 -clientport 27105 \
-port 27115 -clientport 27105 \
+exec openvibe_proton_client.cfg \
+exec openvibe_proton_client.cfg \
+ +exec openvibe_client_default.cfg \
"${CONNECT_ARGS[@]}"
"${CONNECT_ARGS[@]}"
@@ -81,16 +81,24 @@ export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"
@@ -81,16 +81,24 @@ export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"
# frame with no error trace. Default off for reliability.
# frame with no error trace. Default off for reliability.
export DXVK_ASYNC="${DXVK_ASYNC:-0}"
export DXVK_ASYNC="${DXVK_ASYNC:-0}"
+# The OpenVibe HTML console replaces the stock Source console; pass
+# OPENVIBE_STOCK_CONSOLE=1 to get the engine console back for debugging.
+CONSOLE_ARGS=""
+if [[ "${OPENVIBE_STOCK_CONSOLE:-0}" == "1" ]]; then
+ CONSOLE_ARGS="-console"
+fi
+
# Single canonical log via -condebug (writes game/openvibe.games/console.log).
# Single canonical log via -condebug (writes game/openvibe.games/console.log).
# Do NOT also set +con_logfile: it hijacks the console log mid-startup, so the
# Do NOT also set +con_logfile: it hijacks the console log mid-startup, so the
# two split and neither is complete. -condebug alone keeps one full log.
# two split and neither is complete. -condebug alone keeps one full log.
exec "$GE_PROTON/proton" waitforexitandrun \
exec "$GE_PROTON/proton" waitforexitandrun \
"$HL2_EXE" \
"$HL2_EXE" \
-game "$GAME_DIR" \
-game "$GAME_DIR" \
- -console -dev -condebug -novid -sw -w 1280 -h 720 \
+ $CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \
-port 27115 -clientport 27105 \
-port 27115 -clientport 27105 \
-nojoy -insecure -nohltv \
-nojoy -insecure -nohltv \
+developer 1 \
+developer 1 \
+exec openvibe_proton_stability.cfg \
+exec openvibe_proton_stability.cfg \
+exec openvibe_proton_client.cfg \
+exec openvibe_proton_client.cfg \
+ +exec openvibe_client_default.cfg \
$CONNECT_ARGS $STARTUP_MAP_ARGS
$CONNECT_ARGS $STARTUP_MAP_ARGS
@@ -57,12 +57,20 @@ export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"
@@ -57,12 +57,20 @@ export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"
# frame with no error trace. Default off for reliability.
# frame with no error trace. Default off for reliability.
export DXVK_ASYNC="${DXVK_ASYNC:-0}"
export DXVK_ASYNC="${DXVK_ASYNC:-0}"
+# The OpenVibe HTML console replaces the stock Source console; pass
+# OPENVIBE_STOCK_CONSOLE=1 to get the engine console back for debugging.
+CONSOLE_ARGS=""
+if [[ "${OPENVIBE_STOCK_CONSOLE:-0}" == "1" ]]; then
+ CONSOLE_ARGS="-console"
+fi
+
exec "$GE_PROTON/proton" waitforexitandrun \
exec "$GE_PROTON/proton" waitforexitandrun \
"$HL2_EXE" \
"$HL2_EXE" \
-game "$GAME_DIR" \
-game "$GAME_DIR" \
- -console -dev -condebug -novid -sw -w 1280 -h 720 \
+ $CONSOLE_ARGS -dev -condebug -novid -sw -w 1280 -h 720 \
-port 27115 -clientport 27105 \
-port 27115 -clientport 27105 \
+developer 1 \
+developer 1 \
+con_logfile openvibe_proton_console.log \
+con_logfile openvibe_proton_console.log \
+exec openvibe_proton_client.cfg \
+exec openvibe_proton_client.cfg \
+ +exec openvibe_client_default.cfg \
$CONNECT_ARGS
$CONNECT_ARGS
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
@@ -4,6 +4,7 @@ import { createServer } from 'node:http';
import { extname, join, normalize, resolve, sep } from 'node:path';
import { extname, join, normalize, resolve, sep } from 'node:path';
const root = resolve(process.env.OPENVIBE_ROOT || join(process.env.HOME || '.', 'src/openvibe-source'));
const root = resolve(process.env.OPENVIBE_ROOT || join(process.env.HOME || '.', 'src/openvibe-source'));
+const clientRoot = join(root, 'client');
const launcherRoot = join(root, 'launcher');
const launcherRoot = join(root, 'launcher');
const host = process.env.OPENVIBE_CLIENT_UI_HOST || '127.0.0.1';
const host = process.env.OPENVIBE_CLIENT_UI_HOST || '127.0.0.1';
const port = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);
const port = Number(process.env.OPENVIBE_CLIENT_UI_PORT || 5173);
@@ -45,14 +46,14 @@ function fileForUrl(url) {
@@ -45,14 +46,14 @@ function fileForUrl(url) {
const parsed = new URL(url, `http://${host}:${port}`);
const parsed = new URL(url, `http://${host}:${port}`);
let pathname = decodeURIComponent(parsed.pathname);
let pathname = decodeURIComponent(parsed.pathname);
+ // The unified OpenVibe app (repo-root client/) is served for /client/*.
+ // This matches the backend's static mapping (backend serves client/ at /client/).
if (pathname === '/' || pathname === '/client' || pathname === '/client/') {
if (pathname === '/' || pathname === '/client' || pathname === '/client/') {
- return join(launcherRoot, 'index.html');
+ return join(clientRoot, 'index.html');
}
}
if (!pathname.startsWith('/client/')) {
if (!pathname.startsWith('/client/')) {
- if (pathname === '/styles.css' || pathname === '/renderer.js' || pathname === '/preload.js') {
- return join(launcherRoot, pathname.slice(1));
- }
+ // Launcher assets (icons etc.) stay available for anything that still references them.
if (pathname.startsWith('/assets/')) {
if (pathname.startsWith('/assets/')) {
const asset = normalize(join(launcherRoot, pathname.slice(1)));
const asset = normalize(join(launcherRoot, pathname.slice(1)));
return asset.startsWith(launcherRoot + sep) ? asset : null;
return asset.startsWith(launcherRoot + sep) ? asset : null;
@@ -61,8 +62,8 @@ function fileForUrl(url) {
@@ -61,8 +62,8 @@ function fileForUrl(url) {
}
}
pathname = pathname.slice('/client/'.length);
pathname = pathname.slice('/client/'.length);
- const file = normalize(join(launcherRoot, pathname));
- if (!file.startsWith(launcherRoot + sep)) {
+ const file = normalize(join(clientRoot, pathname));
+ if (!file.startsWith(clientRoot + sep)) {
return null;
return null;
}
}
@@ -104,5 +105,5 @@ const server = createServer((req, res) => {
@@ -104,5 +105,5 @@ const server = createServer((req, res) => {
});
});
server.listen(port, host, () => {
server.listen(port, host, () => {
- console.log(`[openvibe-ui] serving ${launcherRoot} at http://${host}:${port}/client`);
+ console.log(`[openvibe-ui] serving ${clientRoot} at http://${host}:${port}/client`);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+// End-to-end IPC smoke: boots the server-realm and client-realm ov-runtime
+// hosts on real TCP ports, connects fake "game DLL" sockets to both (playing
+// the C++ side), and drives the full message flow:
+// hello -> framework load -> player_connect -> PlayerSay -> net C->S -> net S->C
+// Verifies the sandbox Q-menu spawn round-trips and the HUD/net wiring works
+// across processes exactly as it will in-game (ov_js_backend node).
+import { spawn } from "node:child_process";
+import net from "node:net";
+import path from "node:path";
+import url from "node:url";
+
+const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
+const RT = path.join(ROOT, "engine", "openvibe-js-runtime", "ov-runtime.js");
+
+const SV_PORT = 46999, SV_CTRL = 46997, CL_PORT = 46998, CL_CTRL = 46996;
+
+function wait(ms) { return new Promise((r) => setTimeout(r, ms)); }
+
+function startRuntime(realm, port, ctrl) {
+ const p = spawn(process.execPath, [RT, "--realm", realm, "--mode", "sandbox",
+ "--port", String(port), "--ctrl-port", String(ctrl), "--root", ROOT], { stdio: ["ignore", "pipe", "pipe"] });
+ const lines = [];
+ p.stdout.on("data", (d) => lines.push(...String(d).split("\n").filter(Boolean)));
+ p.stderr.on("data", (d) => lines.push(...String(d).split("\n").filter(Boolean)));
+ return { proc: p, lines };
+}
+
+function connectGame(port) {
+ return new Promise((resolve, reject) => {
+ const inbox = [];
+ const sock = net.connect(port, "127.0.0.1", () => resolve({ sock, inbox, send: (o) => sock.write(JSON.stringify(o) + "\n") }));
+ let buf = "";
+ sock.on("data", (d) => {
+ buf += d;
+ let nl;
+ while ((nl = buf.indexOf("\n")) >= 0) {
+ const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1);
+ if (line) { try { inbox.push(JSON.parse(line)); } catch {} }
+ }
+ });
+ sock.on("error", reject);
+ });
+}
+
+let failures = 0;
+function ok(cond, label) {
+ if (!cond) { failures++; console.error(` FAIL ${label}`); }
+ else console.log(` ok ${label}`);
+}
+
+const sv = startRuntime("server", SV_PORT, SV_CTRL);
+const cl = startRuntime("client", CL_PORT, CL_CTRL);
+await wait(1500);
+
+try {
+ // fake game DLL connections (what the C++ COpenVibeIPC does)
+ const svGame = await connectGame(SV_PORT);
+ const clGame = await connectGame(CL_PORT);
+
+ svGame.send({ t: "hello", map: "ov_hub" });
+ clGame.send({ t: "hello", map: "ov_hub" });
+ await wait(700);
+
+ svGame.send({ t: "player_connect", userId: 3, name: "Echo", steamId: "STEAM_0:1:3", entIndex: 1 });
+ clGame.send({ t: "local_player", userId: 3, name: "Echo", steamId: "STEAM_0:1:3", entIndex: 1 });
+ await wait(200);
+
+ // PlayerSay routes chat commands: !q prints Q menu via {"t":"chat"} lines back
+ svGame.send({ t: "say", userId: 3, playerName: "Echo", text: "!q" });
+ await wait(300);
+ ok(svGame.inbox.some((m) => m.t === "chat" && /Q Menu/.test(m.msg || "")), "server: !q chat command replied via IPC");
+
+ // client -> server net: Q-menu spawn (client realm net.SendToServer -> game 'net' line)
+ const evalRes = await fetch(`http://127.0.0.1:${CL_CTRL}/eval`, {
+ method: "POST", body: JSON.stringify({ code: 'net.Start("OV_Sandbox_Spawn");net.WriteString("crate");net.SendToServer();"sent"' })
+ }).then((r) => r.json());
+ ok(evalRes.ok && evalRes.result === "sent", "client: eval net.SendToServer");
+ await wait(200);
+ const clNetOut = clGame.inbox.find((m) => m.t === "net" && m.toServer);
+ ok(!!clNetOut, "client runtime emitted net line to game DLL (ov_net path)");
+
+ // game DLL would forward that to the server as {"t":"net", userId, ...}
+ svGame.send({ t: "net", userId: 3, name: clNetOut.name, payload: clNetOut.payload });
+ await wait(300);
+ ok(svGame.inbox.some((m) => m.t === "runcmd" && /ov_fortwars_spawn crate/.test(m.cmd || "")), "server: net.Receive validated + ran spawn command");
+
+ // server -> client net: OV_Sandbox_Welcome on spawn
+ svGame.send({ t: "event", name: "PlayerInitialSpawn", args: [{ __player: true, userId: 3 }] });
+ await wait(300);
+ const welcome = svGame.inbox.find((m) => m.t === "net" && !m.toServer);
+ ok(!!welcome, "server: PlayerInitialSpawn pushed net message to client");
+ if (welcome) {
+ // game DLL delivers it to the client runtime via the OVNet usermessage path
+ clGame.send({ t: "net", name: welcome.name, payload: welcome.payload });
+ await wait(300);
+ const state = await fetch(`http://127.0.0.1:${CL_CTRL}/eval`, {
+ method: "POST", body: JSON.stringify({ code: "1" })
+ }).then((r) => r.json());
+ ok(state.ok, "client runtime alive after net delivery");
+ ok(cl.lines.some((l) => /OV_Sandbox_Welcome/.test(l)), "client: net.Receive fired for OV_Sandbox_Welcome");
+ }
+
+ // hot-reload signal: server /state shows gamemode + hook events
+ const st = await fetch(`http://127.0.0.1:${SV_CTRL}/state`).then((r) => r.json());
+ ok(st.ok && st.gamemode && st.gamemode.mode === "sandbox" && st.gameConnected, "server /state reflects connected game + gamemode");
+} catch (e) {
+ failures++;
+ console.error(" FAIL harness error:", e.message);
+} finally {
+ sv.proc.kill(); cl.proc.kill();
+}
+
+if (failures) { console.error(`[smoke-runtime-ipc] ${failures} FAILURES`); process.exit(1); }
+console.log("[smoke-runtime-ipc] ALL PASS");
new file mode 100644
new file mode 100644
@@ -0,0 +1,156 @@
@@ -0,0 +1,156 @@
+#!/usr/bin/env node
+// Smoke test for the unified OpenVibe GUI (client/).
+//
+// 1. Starts tools/serve-client-ui.mjs on an ephemeral port and asserts:
+// - GET /health → { ok: true }
+// - GET /client/ → unified HTML with markers for every route
+// - GET /client/ui-sync.js → the shared sync layer serves
+// 2. Verifies the backend path mapping (backend/src/index.ts serves the
+// repo-root client/ directory at /client/) by reading client/index.html
+// straight from disk — no backend start needed — and asserting it is the
+// same unified app.
+//
+// Exits non-zero on any failure.
+
+import { spawn } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { createServer } from 'node:net';
+import { dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const root = resolve(__dirname, '..');
+const serverScript = join(__dirname, 'serve-client-ui.mjs');
+const clientIndex = join(root, 'client', 'index.html');
+
+const ROUTE_MARKERS = [
+ 'route-portal',
+ 'route-servers',
+ 'route-leaderboard',
+ 'route-inventory',
+ 'route-shop',
+ 'route-settings',
+ 'route-options',
+ 'route-console',
+];
+
+let failures = 0;
+function check(name, ok, detail = '') {
+ if (ok) {
+ console.log(` ok ${name}`);
+ } else {
+ failures += 1;
+ console.error(` FAIL ${name}${detail ? ` — ${detail}` : ''}`);
+ }
+}
+
+function ephemeralPort() {
+ return new Promise((resolvePort, reject) => {
+ const srv = createServer();
+ srv.listen(0, '127.0.0.1', () => {
+ const { port } = srv.address();
+ srv.close(() => resolvePort(port));
+ });
+ srv.on('error', reject);
+ });
+}
+
+async function waitForHealth(base, timeoutMs = 8000) {
+ const started = Date.now();
+ while (Date.now() - started < timeoutMs) {
+ try {
+ const res = await fetch(`${base}/health`);
+ if (res.ok) return true;
+ } catch {}
+ await new Promise((r) => setTimeout(r, 150));
+ }
+ return false;
+}
+
+function assertUnifiedHtml(label, html) {
+ for (const marker of ROUTE_MARKERS) {
+ check(`${label}: contains ${marker}`, html.includes(marker));
+ }
+ check(`${label}: contains HUD overlay (ov-hud)`, html.includes('ov-hud'));
+ check(`${label}: loads ui-sync.js`, html.includes('ui-sync.js'));
+ check(`${label}: openvibe:// bridge present`, html.includes('openvibe://'));
+ check(`${label}: console eval wiring (41997)`, html.includes('41997'));
+ check(`${label}: client runtime wiring (41996)`, html.includes('41996'));
+}
+
+async function main() {
+ const port = await ephemeralPort();
+ const base = `http://127.0.0.1:${port}`;
+
+ console.log(`[smoke-ui] starting serve-client-ui.mjs on :${port}`);
+ const child = spawn(process.execPath, [serverScript], {
+ cwd: root,
+ env: {
+ ...process.env,
+ OPENVIBE_ROOT: root,
+ OPENVIBE_CLIENT_UI_PORT: String(port),
+ OPENVIBE_CLIENT_UI_HOST: '127.0.0.1',
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stderr.on('data', (d) => process.stderr.write(`[serve] ${d}`));
+
+ try {
+ check('server: /health reachable', await waitForHealth(base));
+
+ // /health payload
+ try {
+ const health = await (await fetch(`${base}/health`)).json();
+ check('server: /health ok:true', health && health.ok === true, JSON.stringify(health));
+ } catch (e) {
+ check('server: /health ok:true', false, e.message);
+ }
+
+ // /client/ serves the unified app
+ try {
+ const res = await fetch(`${base}/client/`);
+ check('server: GET /client/ → 200', res.status === 200, `status ${res.status}`);
+ check('server: /client/ is HTML', (res.headers.get('content-type') || '').includes('text/html'));
+ const html = await res.text();
+ assertUnifiedHtml('server: /client/', html);
+ } catch (e) {
+ check('server: GET /client/', false, e.message);
+ }
+
+ // /client/ui-sync.js serves
+ try {
+ const res = await fetch(`${base}/client/ui-sync.js`);
+ check('server: GET /client/ui-sync.js → 200', res.status === 200, `status ${res.status}`);
+ const js = await res.text();
+ check('server: ui-sync storage key', js.includes('openvibe.ui.state.v1'));
+ check('server: ui-sync channel', js.includes('openvibe-ui-sync-v1'));
+ check('server: ui-sync routes include console/options/hud',
+ js.includes("'console'") && js.includes("'options'") && js.includes("'hud'"));
+ check('server: ui-sync exposes OpenVibeShell', js.includes('OpenVibeShell'));
+ } catch (e) {
+ check('server: GET /client/ui-sync.js', false, e.message);
+ }
+
+ // Backend path mapping: backend serves the same repo-root client/ dir at
+ // /client/ — assert the on-disk file is the unified app.
+ try {
+ const html = readFileSync(clientIndex, 'utf8');
+ assertUnifiedHtml('backend(disk): client/index.html', html);
+ } catch (e) {
+ check('backend(disk): read client/index.html', false, e.message);
+ }
+ } finally {
+ child.kill();
+ }
+
+ if (failures > 0) {
+ console.error(`[smoke-ui] FAILED — ${failures} assertion(s) failed`);
+ process.exit(1);
+ }
+ console.log('[smoke-ui] PASS — all assertions succeeded');
+}
+
+main().catch((e) => {
+ console.error('[smoke-ui] fatal:', e);
+ process.exit(1);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,484 @@
@@ -0,0 +1,484 @@
+#!/usr/bin/env node
+// GModJS framework test harness — runs a paired server+client realm entirely
+// in Node (zero C++), with a loopback net transport, against the real
+// game/openvibe.games/js tree. Covers hooks, net, entities, scripted ents,
+// NW/DT sync, file sync manifest, include/AddCSJSFile, concommand, loader,
+// and hot-repatch of scripted entities.
+import fs from "node:fs";
+import path from "node:path";
+import vm from "node:vm";
+import url from "node:url";
+
+const ROOT = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "..");
+const MOD = path.join(ROOT, "game", "openvibe.games");
+
+let failures = 0;
+let checks = 0;
+function ok(cond, label) {
+ checks++;
+ if (!cond) { failures++; console.error(` FAIL ${label}`); }
+ else console.log(` ok ${label}`);
+}
+function section(name) { console.log(`\n== ${name}`); }
+
+function modRel(p) {
+ const norm = path.normalize(p).replace(/^([/\\]|\.\.)+/, "");
+ return path.join(MOD, norm);
+}
+
+// ---- realm factory ----
+function makeRealm(realmName, mode, transport) {
+ const logs = [];
+ const isServer = realmName === "server";
+ const players = {}; // userId -> record
+ const writes = {}; // virtual client write cache (js/ov_downloads/...)
+
+ const OV = {
+ log: (m) => logs.push(`[log] ${m}`),
+ warn: (m) => logs.push(`[warn] ${m}`),
+ error: (m) => { logs.push(`[error] ${m}`); console.error(` [${realmName} JS ERROR]`, m); },
+ isServer: () => isServer,
+ getMode: () => mode,
+ getMapName: () => "test_map",
+ time: () => transport.now(),
+ readFile: (p) => {
+ if (writes[p] !== undefined) return writes[p];
+ try { return fs.readFileSync(modRel(p), "utf8"); } catch { return null; }
+ },
+ fileExists: (p) => {
+ if (writes[p] !== undefined) return true;
+ try { fs.statSync(modRel(p)); return true; } catch { return false; }
+ },
+ listDir: (d) => { try { return fs.readdirSync(modRel(d)); } catch { return []; } },
+ writeFile: (p, content) => { writes[p] = String(content); return true; },
+ players: () => Object.values(players).map((p) => p.native),
+ playerByUserId: (id) => (players[id] ? players[id].native : null),
+ broadcast: (m) => logs.push(`[broadcast] ${m}`),
+ serverCommand: (c) => logs.push(`[serverCommand] ${c}`),
+ netEmit: (ids, name, payload) => transport.serverEmit(ids, name, payload),
+ netSendToServer: (name, payload) => transport.clientSend(name, payload),
+ fireHook: (...a) => ctxg.hook.Run(...a),
+ localPlayer: () => null,
+ reward: () => {}, endMatch: () => {},
+ };
+
+ const ctxg = vm.createContext({ console, OV });
+ ctxg.globalThis = ctxg;
+
+ const core = ["core/hook.js", "core/gamemode.js", "bridge.js", "core/command.js", "core/timer.js"];
+ for (const f of core) {
+ const code = fs.readFileSync(path.join(MOD, "js", f), "utf8");
+ vm.runInContext(code, ctxg, { filename: `js/${f}` });
+ }
+ ctxg.OVLoader.loadAll({ mode });
+
+ function addPlayer(userId, name) {
+ const rec = { userId, name, team: 0, health: 100 };
+ rec.native = {
+ userId: () => userId, entIndex: () => userId,
+ steamId: () => `STEAM_0:1:${userId}`, name: () => name,
+ health: () => rec.health, setHealth: (v) => { rec.health = v; },
+ team: () => rec.team, setTeam: (v) => { rec.team = v; },
+ chat: (m) => logs.push(`[chat:${name}] ${m}`),
+ runCommand: (c) => logs.push(`[runCommand:${name}] ${c}`),
+ };
+ players[userId] = rec;
+ return rec.native;
+ }
+
+ return { name: realmName, OV, ctx: ctxg, logs, addPlayer, writes };
+}
+
+// ---- loopback transport with virtual clock ----
+function makeTransport() {
+ let t = 1000;
+ const queue = [];
+ const api = {
+ now: () => t,
+ tick: (dt = 0.1) => { t += dt; api.flush(); },
+ flush: () => {
+ while (queue.length) {
+ const m = queue.shift();
+ try { m(); } catch (e) { console.error(" transport dispatch error:", e.message); }
+ }
+ },
+ serverEmit: null, clientSend: null,
+ };
+ return api;
+}
+
+const transport = makeTransport();
+const server = makeRealm("server", "sandbox", transport);
+const client = makeRealm("client", "sandbox", transport);
+
+// Wire loopback AFTER both realms exist.
+transport.serverEmit = (idsCsv, name, payload) => {
+ queueDispatch(client, name, payload, null);
+};
+let serverSenderPly = null;
+transport.clientSend = (name, payload) => {
+ queueDispatch(server, name, payload, serverSenderPly);
+};
+const pending = [];
+function queueDispatch(realm, name, payload, ply) {
+ pending.push(() => realm.ctx.hook.Run("OVNetReceive", name, payload, ply));
+}
+function pump() {
+ while (pending.length) {
+ const fn = pending.shift();
+ fn();
+ }
+}
+
+// The transport in makeRealm captured `transport` closure functions set above.
+
+section("realms");
+ok(server.ctx.SERVER === true && server.ctx.CLIENT === false, "server realm globals");
+ok(client.ctx.SERVER === false && client.ctx.CLIENT === true, "client realm globals");
+ok(typeof server.ctx.OVLoader === "object", "loader present");
+ok(typeof server.ctx.ents === "object" && typeof server.ctx.Entity === "function", "entity system present");
+ok(typeof server.ctx.AddCSJSFile === "function" && typeof server.ctx.include === "function", "file library present");
+
+section("hook semantics");
+{
+ const H = server.ctx.hook;
+ let calls = [];
+ H.Add("TestHook", "a", () => { calls.push("a"); });
+ H.Add("TestHook", "b", () => { calls.push("b"); return false; });
+ H.Add("TestHook", "c", () => { calls.push("c"); });
+ const result = H.Run("TestHook");
+ ok(result === false, "false short-circuits (not treated as nil)");
+ ok(calls.join(",") === "a,b", "later hooks skipped after short-circuit");
+ H.Remove("TestHook", "b"); H.Remove("TestHook", "a"); H.Remove("TestHook", "c");
+
+ // object identifier with IsValid
+ const obj = { valid: true, IsValid() { return this.valid; }, hits: 0 };
+ H.Add("ObjHook", obj, (self) => { self.hits++; });
+ H.Run("ObjHook");
+ ok(obj.hits === 1, "object identifier receives self");
+ obj.valid = false;
+ H.Run("ObjHook");
+ ok(obj.hits === 1, "invalid object hook auto-removed");
+ ok((H.GetTable("ObjHook") || []).length === 0, "auto-removed from table");
+}
+
+section("gamemode inheritance");
+{
+ const g = server.ctx;
+ ok(g.GAMEMODE && g.GAMEMODE.mode === "sandbox", "active gamemode is sandbox");
+ ok(typeof g.gamemode.Register === "function" && typeof g.DeriveGamemode === "function", "Register/DeriveGamemode exist");
+ ok(g.baseclass.Get("gamemode_base") !== null, "baseclass gamemode_base set");
+}
+
+section("net library");
+{
+ const S = server.ctx, C = client.ctx;
+ S.util.AddNetworkString("TestMsg");
+ let got = null;
+ C.net.Receive("testmsg", () => { // case-insensitive
+ got = {
+ i: C.net.ReadInt(), u: C.net.ReadUInt(), f: C.net.ReadFloat(),
+ b: C.net.ReadBool(), bit: C.net.ReadBit(), s: C.net.ReadString(),
+ d: C.net.ReadData(4), v: C.net.ReadVector(), a: C.net.ReadAngle(),
+ c: C.net.ReadColor(), t: C.net.ReadTable(), u64: C.net.ReadUInt64(),
+ past: C.net.ReadString(),
+ };
+ });
+ ok(S.net.Start("TestMsg") === true, "net.Start on pooled name");
+ S.net.WriteInt(-42, 16); S.net.WriteUInt(42, 8); S.net.WriteFloat(3.25);
+ S.net.WriteBool(true); S.net.WriteBit(true); S.net.WriteString("hello");
+ S.net.WriteData("abcdEXTRA", 4); S.net.WriteVector({ x: 1, y: 2, z: 3 });
+ S.net.WriteAngle({ p: 10, y: 20, r: 30 }); S.net.WriteColor({ r: 255, g: 128, b: 0 });
+ S.net.WriteTable({ k: "v", n: 5 }); S.net.WriteUInt64("18446744073709551615");
+ S.net.Broadcast();
+ pump();
+ ok(got !== null, "client received broadcast");
+ ok(got && got.i === -42 && got.u === 42 && Math.abs(got.f - 3.25) < 1e-9, "numeric roundtrip");
+ ok(got && got.b === true && got.bit === 1, "bool/bit roundtrip (bit reads as number)");
+ ok(got && got.s === "hello" && got.d === "abcd", "string/data roundtrip");
+ ok(got && got.v.z === 3 && got.a.r === 30 && got.c.a === 255, "vector/angle/color roundtrip");
+ ok(got && got.t.k === "v" && got.u64 === "18446744073709551615", "table/uint64 roundtrip");
+ ok(got && got.past === "", "read past end returns default");
+
+ ok(S.net.Start("NotPooled") === false, "net.Start rejects unpooled name on server");
+
+ // client -> server with sender identity
+ const alice = server.addPlayer(7, "Alice");
+ serverSenderPly = alice;
+ let senderSeen = null, ageSeen = 0;
+ S.util.AddNetworkString("SendAge");
+ S.net.Receive("SendAge", (len, ply) => { ageSeen = S.net.ReadUInt(8); senderSeen = ply; });
+ C.net.Start("SendAge"); C.net.WriteUInt(29, 8); C.net.SendToServer();
+ pump();
+ ok(ageSeen === 29, "client->server payload");
+ ok(senderSeen === alice, "server receiver gets authoritative sender");
+
+ // chunking: payload far above CHUNK_SIZE
+ S.util.AddNetworkString("BigMsg");
+ const bigString = "x".repeat(5000);
+ let bigGot = null;
+ C.net.Receive("BigMsg", () => { bigGot = C.net.ReadString(); });
+ S.net.Start("BigMsg"); S.net.WriteString(bigString); S.net.Broadcast();
+ pump();
+ ok(bigGot === bigString, "chunked message reassembled (5KB)");
+
+ // oversize cap
+ S.net.Start("BigMsg"); S.net.WriteString("y".repeat(70000));
+ S.net.Broadcast();
+ pump();
+ ok(bigGot === bigString, "oversize >64KB message dropped");
+
+ // rate limiting
+ S.util.AddNetworkString("Spammy");
+ let spamCount = 0;
+ S.net.Receive("Spammy", () => { spamCount++; });
+ S.net.SetRateLimit("Spammy", 3);
+ for (let i = 0; i < 10; i++) { C.net.Start("Spammy"); C.net.SendToServer(); }
+ pump();
+ ok(spamCount === 3, `rate limit enforced (got ${spamCount}/10, cap 3)`);
+}
+
+section("entities + scripted ents");
+{
+ const S = server.ctx;
+ S.scripted_ents.Register({
+ Type: "anim",
+ Base: "base_gmodentity",
+ PrintName: "Test Crate",
+ SetupDataTables() { this.NetworkVar("Int", 0, "Charge"); },
+ Initialize() { this.initialized = true; this.SetModel("models/crate.mdl"); },
+ Think() { this.thinks = (this.thinks || 0) + 1; },
+ OnTakeDamage(info) { this.SetHealth(this.Health() - info.GetDamage()); if (this.Health() <= 0) this.Remove(); },
+ OnRemove() { this.removedHookRan = true; },
+ }, "test_crate");
+
+ ok(S.scripted_ents.GetStored("test_crate") !== null, "scripted_ents.Register stored");
+ ok(S.scripted_ents.IsBasedOn("test_crate", "base_anim"), "IsBasedOn walks Base chain");
+
+ const crate = S.ents.Create("test_crate");
+ ok(crate.IsValid(), "ents.Create returns valid entity");
+ ok(typeof crate.GetCharge === "function", "SetupDataTables ran at creation (NetworkVar accessors)");
+ crate.SetPos({ x: 10, y: 0, z: 0 });
+ crate.Spawn();
+ ok(crate.initialized === true, "Spawn runs Initialize");
+ ok(crate.GetModel() === "models/crate.mdl", "SetModel/GetModel");
+ ok(crate.GetClass() === "test_crate", "GetClass");
+
+ // find queries
+ ok(S.ents.FindByClass("test_*").length === 1, "FindByClass wildcard");
+ ok(S.ents.FindInSphere({ x: 0, y: 0, z: 0 }, 20).includes(crate), "FindInSphere hit");
+ // (players idle at the origin also live in the entity list, so probe far away)
+ ok(S.ents.FindInSphere({ x: 500, y: 500, z: 500 }, 5).length === 0, "FindInSphere miss");
+ ok(S.Entity(crate.EntIndex()) === crate, "Entity(index) resolves");
+
+ // think pump
+ transport.tick();
+ S.gamemode.call("Think");
+ ok(crate.thinks >= 1, "ENT:Think driven by Think hook");
+
+ // NextThink schedule
+ const before = crate.thinks;
+ crate.NextThink(transport.now() + 100);
+ S.gamemode.call("Think");
+ ok(crate.thinks === before, "NextThink defers Think");
+
+ // NW + DT replication to client shell
+ S.util; // pooled names already registered by entity.js
+ crate.SetNWString("label", "hi");
+ crate.SetCharge(7);
+ pump();
+ const shell = client.ctx.ents.GetByIndex(crate.EntIndex());
+ ok(shell && shell.IsValid(), "client shell entity materialized");
+ ok(shell && shell.GetNWString("label") === "hi", "NW var replicated to client");
+ ok(shell && shell._r.dt.Charge === 7, "DTVar replicated to client");
+
+ // damage + removal lifecycle
+ crate.SetHealth(10);
+ let removedFired = false;
+ crate.CallOnRemove("test", () => { removedFired = true; });
+ crate.TakeDamage(25, S.NULL, S.NULL);
+ ok(crate.Health() <= 0, "OnTakeDamage applied damage");
+ S.gamemode.call("Think"); // flush deferred removals
+ ok(!crate.IsValid(), "Remove finalized at end of tick");
+ ok(removedFired && crate.removedHookRan === true, "CallOnRemove + OnRemove fired");
+
+ // hot-repatch
+ const crate2 = S.ents.Create("test_crate");
+ crate2.Spawn();
+ S.scripted_ents.Register({
+ Type: "anim", Base: "base_gmodentity",
+ Think() { this.newThink = true; },
+ OnReloaded() { this.reloaded = true; },
+ }, "test_crate");
+ ok(crate2.reloaded === true, "re-register hot-patches live instances (OnReloaded)");
+ S.gamemode.call("Think");
+ ok(crate2.newThink === true, "live instance uses new methods");
+ crate2.Remove(); S.gamemode.call("Think");
+
+ // clientside entity
+ const cs = client.ctx.ents.CreateClientside("test_crate");
+ ok(client.ctx.ents.Create("x").IsValid() === false, "ents.Create is server-only (returns NULL)");
+ ok(cs.EntIndex() === -1, "clientside entity reports EntIndex -1");
+}
+
+section("player class");
+{
+ const S = server.ctx;
+ const bobNative = server.addPlayer(9, "Bob");
+ const bob = S.Player.fromNative(bobNative);
+ ok(bob.__ovPlayer === true && bob.IsPlayer(), "Player.fromNative wraps");
+ ok(S.Player.fromNative(bobNative) === bob, "wrap cached by UserID");
+ ok(bob.Nick() === "Bob" && bob.UserID() === 9, "identity accessors");
+ bob.SetTeam(3);
+ ok(bob.Team() === 3 && bobNative.team() === 3, "SetTeam hits native handle");
+ ok(bob.name() === "Bob" && typeof bob.chat === "function", "legacy lowercase API intact");
+ ok(S.player.GetByUserID(9) === bob, "player.GetByUserID");
+ ok(S.player.GetBySteamID("STEAM_0:1:9") === bob, "player.GetBySteamID");
+ // gamemode.call wraps native handles
+ let seen = null;
+ S.hook.Add("WrapCheck", "t", (p) => { seen = p; return true; });
+ S.gamemode.call("WrapCheck", bobNative);
+ ok(seen === bob, "gamemode.call wraps native player args");
+ S.hook.Remove("WrapCheck", "t");
+}
+
+section("file library / AddCSJSFile / include");
+{
+ const S = server.ctx;
+ const manifest = S.file.BuildClientManifest();
+ ok(Array.isArray(manifest) && manifest.length > 0, `client manifest built (${manifest.length} files)`);
+ ok(manifest.some((f) => f.p.startsWith("js/gamemodes/sandbox/"), ), "manifest includes gamemode client files");
+ ok(manifest.every((f) => f.h && f.s > 0), "manifest entries carry hash+size");
+
+ S.AddCSJSFile("core/util.js");
+ const manifest2 = S.file.BuildClientManifest();
+ ok(manifest2.some((f) => f.p === "js/core/util.js"), "AddCSJSFile marks extra file");
+
+ const inc = S.include("core/util.js");
+ ok(S.util.CRC("abc") === S.util.CRC("abc"), "include re-ran a core file without error");
+
+ // client sync: manifest -> request -> data -> OVFilesSynced
+ let synced = false;
+ client.ctx.hook.Add("OVFilesSynced", "t", () => { synced = true; });
+ // simulate: make one file 'missing' on the client by hashing a bogus cache
+ client.writes["js/gamemodes/sandbox/client.js"] = "// stale";
+ const ply7 = S.player.GetByUserID(7);
+ S.hook.Run("PlayerInitialSpawn", ply7);
+ // fire timers (manifest send is delayed 2s)
+ transport.tick(3);
+ S.gamemode.call("Think");
+ pump(); // manifest to client -> request to server -> data to client
+ pump();
+ ok(synced === true, "client file sync completed (OVFilesSynced)");
+ const dl = client.writes["js/ov_downloads/js/gamemodes/sandbox/client.js"];
+ ok(typeof dl === "string" && dl.length > 10, "stale file downloaded into ov_downloads cache");
+}
+
+section("concommand + js_run/js_openscript");
+{
+ const S = server.ctx;
+ let ranArgs = null;
+ S.concommand.Add("test_cmd", (ply, cmd, args, argStr) => { ranArgs = { ply, cmd, args, argStr }; });
+ ok(S.concommand.Dispatch(null, 'test_cmd one "two three"') === true, "concommand.Dispatch handles");
+ ok(ranArgs && ranArgs.args.length === 2 && ranArgs.args[1] === "two three", "quoted arg parsing");
+ ok(S.gamemode.call("ConsoleCommand", "test_cmd x") === false, "ConsoleCommand hook routes to concommand");
+
+ const r1 = S.OVLoader.runString("1 + 41", "test");
+ ok(r1.ok && r1.result === 42, "runString (js_run) returns value");
+ const r2 = S.OVLoader.runString("throw new Error('boom')", "test");
+ ok(!r2.ok && /boom/.test(r2.error), "runString reports errors");
+
+ ok(S.OVLoader.openScript("core/util.js") === true, "openScript (js_openscript) runs js/-relative path");
+ ok(S.OVLoader.openScript("nope/missing.js") === false, "openScript rejects missing path");
+
+ let blockedRan = false;
+ S.concommand.Add("js_run_cl", () => { blockedRan = true; });
+ S.RunConsoleCommand("js_run_cl", "code");
+ ok(blockedRan === false, "RunConsoleCommand blocklist protects js_run_cl");
+}
+
+section("npm require (node_modules)");
+{
+ // The sandbox gamemode requires ov-leftpad through the embedded module
+ // system (core/module.js over OV.readFile) — prove bare-specifier reqs work.
+ const S = server.ctx;
+ const r = S.OVLoader.runString("require('ov-leftpad')('7', 3, '0')", "npm-test");
+ ok(r.ok && r.result === "007", `require('ov-leftpad') via js/node_modules${r.ok ? "" : " — " + r.error}`);
+}
+
+section("all gamemodes end-to-end (server+client realm pairs)");
+for (const mode of ["hub", "sandbox", "prophunt", "deathrun", "fortwars", "traitortown"]) {
+ const tp = makeTransport();
+ const sv = makeRealm("server", mode, tp);
+ const cl = makeRealm("client", mode, tp);
+ const q = [];
+ tp.serverEmit = (ids, name, payload) => q.push(() => cl.ctx.hook.Run("OVNetReceive", name, payload, null));
+ let sender = null;
+ tp.clientSend = (name, payload) => q.push(() => sv.ctx.hook.Run("OVNetReceive", name, payload, sender));
+ const drain = () => { while (q.length) q.shift()(); };
+
+ const hudSeen = [];
+ cl.ctx.hook.Add("OVHudState", "t", (s) => { hudSeen.push(s); });
+ const rolesSeen = [];
+ cl.ctx.hook.Add("OVRoleAssigned", "t", (role) => { rolesSeen.push(role); });
+
+ // players join
+ const p1 = sv.addPlayer(1, "Alpha"), p2 = sv.addPlayer(2, "Bravo");
+ sender = p1;
+ sv.ctx.gamemode.call("Initialize");
+ sv.ctx.gamemode.call("MapInitialize", "test_map");
+ sv.ctx.gamemode.call("PlayerInitialSpawn", p1);
+ sv.ctx.gamemode.call("PlayerInitialSpawn", p2);
+ sv.ctx.gamemode.call("PlayerSpawn", p1);
+ sv.ctx.gamemode.call("PlayerSpawn", p2);
+ drain();
+
+ cl.ctx.gamemode.call("Initialize");
+
+ ok(sv.ctx.GAMEMODE && sv.ctx.GAMEMODE.mode === mode, `[${mode}] server GAMEMODE active`);
+ ok(cl.ctx.GAMEMODE && cl.ctx.GAMEMODE.mode === (["hub", "sandbox"].includes(mode) ? mode : mode), `[${mode}] client GAMEMODE active`);
+ ok(sv.ctx.scripted_ents.GetStored("ov_bouncy_crate") !== null, `[${mode}] SENT registered on server`);
+ ok(cl.ctx.scripted_ents.GetStored("ov_bouncy_crate") !== null, `[${mode}] SENT registered on client`);
+
+ const roundModes = ["prophunt", "deathrun", "fortwars", "traitortown"];
+ if (roundModes.includes(mode)) {
+ ok(Object.keys(sv.ctx.team.GetAllTeams()).length >= 3, `[${mode}] CreateTeams populated team library`);
+ // run countdown -> round start
+ for (let i = 0; i < 20; i++) { tp.tick(1); sv.ctx.gamemode.call("Think"); drain(); }
+ ok(sv.ctx.GAMEMODE._roundState === "active", `[${mode}] round started (state=${sv.ctx.GAMEMODE._roundState})`);
+ const t1 = p1.team ? null : null;
+ const teams = [sv.ctx.player.GetByUserID(1).Team(), sv.ctx.player.GetByUserID(2).Team()];
+ ok(teams.every((t) => t === 2 || t === 3), `[${mode}] players assigned to teams (${teams})`);
+ if (mode !== "fortwars") {
+ ok(rolesSeen.length > 0, `[${mode}] client received role via net (${rolesSeen.join(",")})`);
+ }
+ // HUD ticker
+ for (let i = 0; i < 4; i++) { tp.tick(1); sv.ctx.gamemode.call("Think"); drain(); }
+ ok(hudSeen.length > 0 && hudSeen[hudSeen.length - 1].state === "active", `[${mode}] HUD state replicated to client`);
+ ok(hudSeen[hudSeen.length - 1].teams.length >= 2, `[${mode}] HUD state carries team roster`);
+ // player death -> win condition path exercises endRound
+ sv.ctx.gamemode.call("PlayerDeath", p1, p2);
+ ok(true, `[${mode}] PlayerDeath handled`);
+ } else {
+ // hub/sandbox: no rounds; sandbox exercises Q-menu + entity spawn
+ if (mode === "sandbox") {
+ sender = p1;
+ cl.ctx.net.Start("OV_Sandbox_Spawn"); cl.ctx.net.WriteString("crate"); cl.ctx.net.SendToServer();
+ drain();
+ ok(sv.logs.some((l) => /ov_fortwars_spawn crate/.test(l)), `[${mode}] client Q-menu spawn ran server command`);
+ const r = sv.ctx.command.run(p1, "ent ov_bouncy_crate");
+ ok(sv.ctx.ents.FindByClass("ov_bouncy_crate").length === 1, `[${mode}] !ent spawned scripted entity`);
+ tp.tick(3); sv.ctx.gamemode.call("Think");
+ const crate = sv.ctx.ents.FindByClass("ov_bouncy_crate")[0];
+ ok(crate && crate.GetBounces() >= 1, `[${mode}] bouncy crate Think ran (bounces=${crate && crate.GetBounces()})`);
+ drain();
+ const clCrate = cl.ctx.ents.GetByIndex(crate.EntIndex());
+ ok(clCrate && clCrate._r.dt.Bounces >= 1, `[${mode}] SENT DTVar replicated to client realm`);
+ }
+ ok(hudSeen.length >= 0, `[${mode}] no-round mode loaded cleanly`);
+ }
+}
+
+console.log(`\n${checks - failures}/${checks} checks passed`);
+if (failures) { console.error(`${failures} FAILURES`); process.exit(1); }
+console.log("[test-gmodjs] ALL PASS");