openvibers/source.openvibe.games Commits

Add GModJS platform: GMod-style JS framework, entity/net bridge, unified GUI with console+options, npm hotloading

CO Copilot committed 3 Jul 2026 · a2a14f29fd · 71 files, +6536 −452

- js/core: hook (object ids), gamemode (Register/DeriveGamemode/baseclass),
  net (full GMod surface, pooling, chunking, rate limits), entity/ents/
  scripted_ents (NW+DT replication, hot-repatch), player/team, file
  (AddCSJSFile/include + client sync), concommand, loader (GMod load order)
- sdk/openvibe: ov_js_entity natives, js_run/js_openscript(+_cl, sv_allowcsjs),
  ov_npm, node-backend event forwarding, client runtime events,
  openvibe://cmd|convar bridge, always-on console spew ring buffer,
  js_run results echoed to caller via ClientPrint, ov_console toggle
- Unified GUI: client/index.html + ui-sync.js for launcher/backend/CEF panel;
  console route (SSE + engine-bridge fallback), options route, HUD overlay
- Gamemodes: teams, role net messages, HUD state replication, ov_bouncy_crate
  SENT demo; stock Source console replaced (cfg/openvibe_client_default.cfg)
- Node runtime: control HTTP server (/logs /eval /exec /npm /state), npm
  hotloading with js/vendor file: deps
- Tests: tools/test-gmodjs.mjs (131 checks), smoke-runtime-ipc, smoke-ui

Co-Authored-By: Claude Fable 5 <[email protected]>

Changes

ddiff --git a/.gitignore b/.gitignore
iindex 2560b6b..b07641f 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -48,3 +48,5 @@ artifacts/
_deps/
*.tmp
*.log
++game/openvibe.games/js/node_modules/
++game/openvibe.games/js/ov_downloads/
ddiff --git a/PROJECT_INDEX.md b/PROJECT_INDEX.md
iindex 93b20fc..9147104 100644
---- a/PROJECT_INDEX.md
++++ b/PROJECT_INDEX.md
@@@ -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.mjs
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
ddiff --git a/client/index.html b/client/index.html
iindex aceaecc..70841a8 100644
---- a/client/index.html
++++ b/client/index.html
@@@ -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">&nbsp;</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 commands
function 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, '&#39;');
}
++ // ══════════════════════════════════════════════════════════════════════
++ // 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>
ddiff --git a/client/ui-sync.js b/client/ui-sync.js
nnew file mode 100644
iindex 0000000..c16b869
---- /dev/null
++++ b/client/ui-sync.js
@@@ -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();
++ }
++})();
ddiff --git a/docs/GMODJS_ARCHITECTURE.md b/docs/GMODJS_ARCHITECTURE.md
nnew file mode 100644
iindex 0000000..f39a6c3
---- /dev/null
++++ b/docs/GMODJS_ARCHITECTURE.md
@@@ -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.
ddiff --git a/docs/OPENVIBE_JS_SCRIPTING.md b/docs/OPENVIBE_JS_SCRIPTING.md
iindex aed78d8..f6b1bbd 100644
---- a/docs/OPENVIBE_JS_SCRIPTING.md
++++ b/docs/OPENVIBE_JS_SCRIPTING.md
@@@ -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
```
ddiff --git a/engine/openvibe-js-runtime/ov-runtime.js b/engine/openvibe-js-runtime/ov-runtime.js
iindex 9022f57..6a85277 100644
---- a/engine/openvibe-js-runtime/ov-runtime.js
++++ b/engine/openvibe-js-runtime/ov-runtime.js
@@@ -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 hook
const 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 {} }
ddiff --git a/game/openvibe.games/cfg/autoexec.cfg b/game/openvibe.games/cfg/autoexec.cfg
iindex 4739c11..62baad6 100644
---- a/game/openvibe.games/cfg/autoexec.cfg
++++ b/game/openvibe.games/cfg/autoexec.cfg
@@@ -1,2 +1,3 @@
// OpenVibe autoexec. Safe in both native and Proton modes.
exec openvibe_proton_client.cfg
++exec openvibe_client_default.cfg
ddiff --git a/game/openvibe.games/cfg/openvibe_client_default.cfg b/game/openvibe.games/cfg/openvibe_client_default.cfg
nnew file mode 100644
iindex 0000000..ffa9d29
---- /dev/null
++++ b/game/openvibe.games/cfg/openvibe_client_default.cfg
@@@ -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"
ddiff --git a/game/openvibe.games/js/autorun/openvibe_motd.js b/game/openvibe.games/js/autorun/openvibe_motd.js
nnew file mode 100644
iindex 0000000..95cf6e5
---- /dev/null
++++ b/game/openvibe.games/js/autorun/openvibe_motd.js
@@@ -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");
++ });
++ }
++})();
ddiff --git a/game/openvibe.games/js/bridge.js b/game/openvibe.games/js/bridge.js
iindex a6168fa..c5de1e8 100644
---- a/game/openvibe.games/js/bridge.js
++++ b/game/openvibe.games/js/bridge.js
@@@ -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
ddiff --git a/game/openvibe.games/js/core/command.js b/game/openvibe.games/js/core/command.js
iindex 694cbee..08ed169 100644
---- a/game/openvibe.games/js/core/command.js
++++ b/game/openvibe.games/js/core/command.js
@@@ -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();
ddiff --git a/game/openvibe.games/js/core/concommand.js b/game/openvibe.games/js/core/concommand.js
nnew file mode 100644
iindex 0000000..4ae6ca2
---- /dev/null
++++ b/game/openvibe.games/js/core/concommand.js
@@@ -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");
++})();
ddiff --git a/game/openvibe.games/js/core/entity.js b/game/openvibe.games/js/core/entity.js
nnew file mode 100644
iindex 0000000..d6a5abe
---- /dev/null
++++ b/game/openvibe.games/js/core/entity.js
@@@ -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") + ")");
++})();
ddiff --git a/game/openvibe.games/js/core/ents.js b/game/openvibe.games/js/core/ents.js
nnew file mode 100644
iindex 0000000..52a43dd
---- /dev/null
++++ b/game/openvibe.games/js/core/ents.js
@@@ -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");
++})();
ddiff --git a/game/openvibe.games/js/core/file.js b/game/openvibe.games/js/core/file.js
nnew file mode 100644
iindex 0000000..5fa5cf5
---- /dev/null
++++ b/game/openvibe.games/js/core/file.js
@@@ -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)");
++})();
ddiff --git a/game/openvibe.games/js/core/gamemode.js b/game/openvibe.games/js/core/gamemode.js
iindex 499e1fd..af650a9 100644
---- a/game/openvibe.games/js/core/gamemode.js
++++ b/game/openvibe.games/js/core/gamemode.js
@@@ -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>" -> table
function 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;
ddiff --git a/game/openvibe.games/js/core/hook.js b/game/openvibe.games/js/core/hook.js
iindex b65ee5c..adbc30e 100644
---- a/game/openvibe.games/js/core/hook.js
++++ b/game/openvibe.games/js/core/hook.js
@@@ -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
};
})();
ddiff --git a/game/openvibe.games/js/core/loader.js b/game/openvibe.games/js/core/loader.js
nnew file mode 100644
iindex 0000000..4dd3ace
---- /dev/null
++++ b/game/openvibe.games/js/core/loader.js
@@@ -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 + ")");
++})();
ddiff --git a/game/openvibe.games/js/core/net.js b/game/openvibe.games/js/core/net.js
iindex 06bc89e..6fed7c2 100644
---- a/game/openvibe.games/js/core/net.js
++++ b/game/openvibe.games/js/core/net.js
@@@ -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;
});
}
ddiff --git a/game/openvibe.games/js/core/player.js b/game/openvibe.games/js/core/player.js
nnew file mode 100644
iindex 0000000..4ee2418
---- /dev/null
++++ b/game/openvibe.games/js/core/player.js
@@@ -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");
++})();
ddiff --git a/game/openvibe.games/js/core/realm.js b/game/openvibe.games/js/core/realm.js
nnew file mode 100644
iindex 0000000..43e0a57
---- /dev/null
++++ b/game/openvibe.games/js/core/realm.js
@@@ -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; };
++ }
++})();
ddiff --git a/game/openvibe.games/js/core/team.js b/game/openvibe.games/js/core/team.js
nnew file mode 100644
iindex 0000000..c1631cf
---- /dev/null
++++ b/game/openvibe.games/js/core/team.js
@@@ -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");
++})();
ddiff --git a/game/openvibe.games/js/core/timer.js b/game/openvibe.games/js/core/timer.js
iindex 69f570d..01fc651 100644
---- a/game/openvibe.games/js/core/timer.js
++++ b/game/openvibe.games/js/core/timer.js
@@@ -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;
}
};
ddiff --git a/game/openvibe.games/js/core/util.js b/game/openvibe.games/js/core/util.js
nnew file mode 100644
iindex 0000000..c41178a
---- /dev/null
++++ b/game/openvibe.games/js/core/util.js
@@@ -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;
++})();
ddiff --git a/game/openvibe.games/js/entities/ov_bouncy_crate/cl_init.js b/game/openvibe.games/js/entities/ov_bouncy_crate/cl_init.js
nnew file mode 100644
iindex 0000000..4d10c95
---- /dev/null
++++ b/game/openvibe.games/js/entities/ov_bouncy_crate/cl_init.js
@@@ -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.
++};
ddiff --git a/game/openvibe.games/js/entities/ov_bouncy_crate/init.js b/game/openvibe.games/js/entities/ov_bouncy_crate/init.js
nnew file mode 100644
iindex 0000000..f4de686
---- /dev/null
++++ b/game/openvibe.games/js/entities/ov_bouncy_crate/init.js
@@@ -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.");
++};
ddiff --git a/game/openvibe.games/js/entities/ov_bouncy_crate/shared.js b/game/openvibe.games/js/entities/ov_bouncy_crate/shared.js
nnew file mode 100644
iindex 0000000..7ed2205
---- /dev/null
++++ b/game/openvibe.games/js/entities/ov_bouncy_crate/shared.js
@@@ -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");
++};
ddiff --git a/game/openvibe.games/js/gamemodes/base/client.js b/game/openvibe.games/js/gamemodes/base/client.js
iindex 0a98bc9..d24d5cd 100644
---- a/game/openvibe.games/js/gamemodes/base/client.js
++++ b/game/openvibe.games/js/gamemodes/base/client.js
@@@ -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 });
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/base/server.js b/game/openvibe.games/js/gamemodes/base/server.js
iindex 32e6a8e..a69848d 100644
---- a/game/openvibe.games/js/gamemodes/base/server.js
++++ b/game/openvibe.games/js/gamemodes/base/server.js
@@@ -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
ddiff --git a/game/openvibe.games/js/gamemodes/base/shared.js b/game/openvibe.games/js/gamemodes/base/shared.js
iindex 33569b3..e782261 100644
---- a/game/openvibe.games/js/gamemodes/base/shared.js
++++ b/game/openvibe.games/js/gamemodes/base/shared.js
@@@ -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") + ")");
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/deathrun/client.js b/game/openvibe.games/js/gamemodes/deathrun/client.js
nnew file mode 100644
iindex 0000000..c673bb8
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/deathrun/client.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/deathrun/manifest.json b/game/openvibe.games/js/gamemodes/deathrun/manifest.json
nnew file mode 100644
iindex 0000000..4a85cac
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/deathrun/manifest.json
@@@ -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 }
++ ]
++}
ddiff --git a/game/openvibe.games/js/gamemodes/deathrun/server.js b/game/openvibe.games/js/gamemodes/deathrun/server.js
iindex 964d5eb..21f91d7 100644
---- a/game/openvibe.games/js/gamemodes/deathrun/server.js
++++ b/game/openvibe.games/js/gamemodes/deathrun/server.js
@@@ -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;
ddiff --git a/game/openvibe.games/js/gamemodes/fortwars/client.js b/game/openvibe.games/js/gamemodes/fortwars/client.js
nnew file mode 100644
iindex 0000000..7f1e2e7
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/fortwars/client.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/fortwars/manifest.json b/game/openvibe.games/js/gamemodes/fortwars/manifest.json
nnew file mode 100644
iindex 0000000..2460f53
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/fortwars/manifest.json
@@@ -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 }
++ ]
++}
ddiff --git a/game/openvibe.games/js/gamemodes/fortwars/server.js b/game/openvibe.games/js/gamemodes/fortwars/server.js
iindex 4eb1a55..d159b9e 100644
---- a/game/openvibe.games/js/gamemodes/fortwars/server.js
++++ b/game/openvibe.games/js/gamemodes/fortwars/server.js
@@@ -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");
ddiff --git a/game/openvibe.games/js/gamemodes/hub/client.js b/game/openvibe.games/js/gamemodes/hub/client.js
nnew file mode 100644
iindex 0000000..87a1169
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/hub/client.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/prophunt/client.js b/game/openvibe.games/js/gamemodes/prophunt/client.js
nnew file mode 100644
iindex 0000000..f3ef85a
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/prophunt/client.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/prophunt/manifest.json b/game/openvibe.games/js/gamemodes/prophunt/manifest.json
nnew file mode 100644
iindex 0000000..9c4cd6a
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/prophunt/manifest.json
@@@ -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 }
++ ]
++}
ddiff --git a/game/openvibe.games/js/gamemodes/prophunt/server.js b/game/openvibe.games/js/gamemodes/prophunt/server.js
iindex 636541d..d48e6d0 100644
---- a/game/openvibe.games/js/gamemodes/prophunt/server.js
++++ b/game/openvibe.games/js/gamemodes/prophunt/server.js
@@@ -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}`);
ddiff --git a/game/openvibe.games/js/gamemodes/sandbox/client.js b/game/openvibe.games/js/gamemodes/sandbox/client.js
iindex 2ad4830..c412aa3 100644
---- a/game/openvibe.games/js/gamemodes/sandbox/client.js
++++ b/game/openvibe.games/js/gamemodes/sandbox/client.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/sandbox/server.js b/game/openvibe.games/js/gamemodes/sandbox/server.js
iindex 9b231da..4bfcb45 100644
---- a/game/openvibe.games/js/gamemodes/sandbox/server.js
++++ b/game/openvibe.games/js/gamemodes/sandbox/server.js
@@@ -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; }
ddiff --git a/game/openvibe.games/js/gamemodes/traitortown/client.js b/game/openvibe.games/js/gamemodes/traitortown/client.js
nnew file mode 100644
iindex 0000000..2e9877d
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/traitortown/client.js
@@@ -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);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/traitortown/manifest.json b/game/openvibe.games/js/gamemodes/traitortown/manifest.json
nnew file mode 100644
iindex 0000000..2fd2732
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/traitortown/manifest.json
@@@ -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 }
++ ]
++}
ddiff --git a/game/openvibe.games/js/gamemodes/traitortown/server.js b/game/openvibe.games/js/gamemodes/traitortown/server.js
iindex c427249..75053ab 100644
---- a/game/openvibe.games/js/gamemodes/traitortown/server.js
++++ b/game/openvibe.games/js/gamemodes/traitortown/server.js
@@@ -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}`);
ddiff --git a/game/openvibe.games/js/node_modules/ov-leftpad/index.js b/game/openvibe.games/js/node_modules/ov-leftpad/index.js
ddeleted file mode 100644
iindex a8e7586..0000000
---- a/game/openvibe.games/js/node_modules/ov-leftpad/index.js
++++ /dev/null
@@@ -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;
--};
ddiff --git a/game/openvibe.games/js/node_modules/ov-leftpad/package.json b/game/openvibe.games/js/node_modules/ov-leftpad/package.json
ddeleted file mode 100644
iindex 2a465cb..0000000
---- a/game/openvibe.games/js/node_modules/ov-leftpad/package.json
++++ /dev/null
@@@ -1 +0,0 @@
--{ "name": "ov-leftpad", "version": "1.0.0", "main": "index.js" }
ddiff --git a/game/openvibe.games/js/package-lock.json b/game/openvibe.games/js/package-lock.json
nnew file mode 100644
iindex 0000000..37a2a47
---- /dev/null
++++ b/game/openvibe.games/js/package-lock.json
@@@ -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"
++ }
++ }
++}
ddiff --git a/game/openvibe.games/js/package.json b/game/openvibe.games/js/package.json
nnew file mode 100644
iindex 0000000..ae22e60
---- /dev/null
++++ b/game/openvibe.games/js/package.json
@@@ -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"
++ }
++}
ddiff --git a/game/openvibe.games/js/vendor/ov-leftpad/index.js b/game/openvibe.games/js/vendor/ov-leftpad/index.js
nnew file mode 100644
iindex 0000000..a8e7586
---- /dev/null
++++ b/game/openvibe.games/js/vendor/ov-leftpad/index.js
@@@ -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;
++};
ddiff --git a/game/openvibe.games/js/vendor/ov-leftpad/package.json b/game/openvibe.games/js/vendor/ov-leftpad/package.json
nnew file mode 100644
iindex 0000000..2a465cb
---- /dev/null
++++ b/game/openvibe.games/js/vendor/ov-leftpad/package.json
@@@ -0,0 +1 @@
++{ "name": "ov-leftpad", "version": "1.0.0", "main": "index.js" }
ddiff --git a/game/openvibe.games/materials/sprites/orangelight1_noz.vmt b/game/openvibe.games/materials/sprites/orangelight1_noz.vmt
nnew file mode 100644
iindex 0000000..775b16f
---- /dev/null
++++ b/game/openvibe.games/materials/sprites/orangelight1_noz.vmt
@@@ -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"
++}
ddiff --git a/launcher/UNIFIED_UI.md b/launcher/UNIFIED_UI.md
iindex 9c6131f..c596ff0 100644
---- a/launcher/UNIFIED_UI.md
++++ b/launcher/UNIFIED_UI.md
@@@ -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 URLs
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
--`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.
ddiff --git a/launcher/main.js b/launcher/main.js
iindex 5357926..ad3c192 100644
---- a/launcher/main.js
++++ b/launcher/main.js
@@@ -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);
});
ddiff --git a/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp b/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp
iindex 4790f07..33b12d0 100644
---- a/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp
++++ b/sdk/openvibe/client/hl2mp/openvibe_js_client.cpp
@@@ -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
{
ddiff --git a/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp b/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
iindex df2e42e..daa1209 100644
---- a/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
++++ b/sdk/openvibe/client/hl2mp/vgui_openvibe_menu.cpp
@@@ -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( "le
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_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
ddiff --git a/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp b/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp
iindex 78a74c6..ca3aea2 100644
---- a/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp
++++ b/sdk/openvibe/server/hl2mp/openvibe_js_server.cpp
@@@ -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;
ddiff --git a/sdk/openvibe/shared/ov_js_bindings.cpp b/sdk/openvibe/shared/ov_js_bindings.cpp
iindex 06e71d2..4efd093 100644
---- a/sdk/openvibe/shared/ov_js_bindings.cpp
++++ b/sdk/openvibe/shared/ov_js_bindings.cpp
@@@ -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)
ddiff --git a/sdk/openvibe/shared/ov_js_entity.cpp b/sdk/openvibe/shared/ov_js_entity.cpp
nnew file mode 100644
iindex 0000000..35e3029
---- /dev/null
++++ b/sdk/openvibe/shared/ov_js_entity.cpp
@@@ -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
ddiff --git a/sdk/openvibe/shared/ov_js_entity.h b/sdk/openvibe/shared/ov_js_entity.h
nnew file mode 100644
iindex 0000000..b6b4184
---- /dev/null
++++ b/sdk/openvibe/shared/ov_js_entity.h
@@@ -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
ddiff --git a/sdk/openvibe/shared/ov_js_runtime.cpp b/sdk/openvibe/shared/ov_js_runtime.cpp
iindex 112452e..aa74871 100644
---- a/sdk/openvibe/shared/ov_js_runtime.cpp
++++ b/sdk/openvibe/shared/ov_js_runtime.cpp
@@@ -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)
ddiff --git a/sdk/openvibe/shared/ov_js_runtime.h b/sdk/openvibe/shared/ov_js_runtime.h
iindex bc34df1..2348112 100644
---- a/sdk/openvibe/shared/ov_js_runtime.h
++++ b/sdk/openvibe/shared/ov_js_runtime.h
@@@ -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);
ddiff --git a/tools/apply-openvibe-sdk.sh b/tools/apply-openvibe-sdk.sh
iindex b424426..236f6ea 100755
---- a/tools/apply-openvibe-sdk.sh
++++ b/tools/apply-openvibe-sdk.sh
@@@ -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"
ddiff --git a/tools/run-client-linux.sh b/tools/run-client-linux.sh
iindex b85a96c..3860223 100755
---- a/tools/run-client-linux.sh
++++ b/tools/run-client-linux.sh
@@@ -35,9 +35,17 @@ if [[ "${1:-}" != "" && "${2:-}" != "" ]]; then
CONNECT_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[@]}"
ddiff --git a/tools/run-client-proton-x64.sh b/tools/run-client-proton-x64.sh
iindex 2ba3874..19ad174 100755
---- a/tools/run-client-proton-x64.sh
++++ b/tools/run-client-proton-x64.sh
@@@ -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
ddiff --git a/tools/run-client-proton.sh b/tools/run-client-proton.sh
iindex 2ce12ac..9b341c6 100755
---- a/tools/run-client-proton.sh
++++ b/tools/run-client-proton.sh
@@@ -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
ddiff --git a/tools/serve-client-ui.mjs b/tools/serve-client-ui.mjs
iindex d60fb55..5b97944 100755
---- a/tools/serve-client-ui.mjs
++++ b/tools/serve-client-ui.mjs
@@@ -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`);
});
ddiff --git a/tools/smoke-runtime-ipc.mjs b/tools/smoke-runtime-ipc.mjs
nnew file mode 100644
iindex 0000000..6065157
---- /dev/null
++++ b/tools/smoke-runtime-ipc.mjs
@@@ -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");
ddiff --git a/tools/smoke-ui.mjs b/tools/smoke-ui.mjs
nnew file mode 100644
iindex 0000000..ec7e611
---- /dev/null
++++ b/tools/smoke-ui.mjs
@@@ -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);
++});
ddiff --git a/tools/test-gmodjs.mjs b/tools/test-gmodjs.mjs
nnew file mode 100644
iindex 0000000..ce96ef8
---- /dev/null
++++ b/tools/test-gmodjs.mjs
@@@ -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");