openvibers/source.openvibe.games Commits

Add Sandbox gamemode with GMod-style Q menu (JS engine)

CO Copilot committed 2 Jul 2026 · e519d9482f · 8 files, +355 −0

New pure-JS gamemode (no C++ rebuild needed):
- js/gamemodes/sandbox/{manifest,shared,server,client}.js — free-build
  sandbox with no rounds; server.js implements a GMod-style Q menu
  (spawn menu) via the embedded JS command registry: !q opens the menu,
  !spawn <prop> / !1..!5 spawn allowlisted props through the existing
  ov_fortwars_spawn C++ path (build flag force-enabled).
- cfg/openvibe_sandbox.cfg sets ov_mode "sandbox".
- tools/run-sandbox.sh: standalone SRCDS launcher on :27020 that forces
  ov_mode sandbox + ov_js_reload after map load (the JS runtime otherwise
  latches hub from the ov_hub map's auto-exec).
- cfg/openvibe_proton_client.cfg: bind q "say !q" so pressing Q opens the
  spawn menu; add ovp_join_sandbox alias.
- tools/run-client-proton-x64.sh: 64-bit Proton launcher (hl2_win64.exe,
  bin/x64 DLLs) — the working path since the SDK ships no 32-bit libs.

Verified: sandbox loads as its own mode; !q renders the menu and
!spawn/!<n> dispatch ov_fortwars_spawn correctly (mocked-runtime test).

Changes

ddiff --git a/game/openvibe.games/cfg/openvibe_proton_client.cfg b/game/openvibe.games/cfg/openvibe_proton_client.cfg
iindex dc42fde..77af907 100644
---- a/game/openvibe.games/cfg/openvibe_proton_client.cfg
++++ b/game/openvibe.games/cfg/openvibe_proton_client.cfg
@@@ -7,3 +7,9 @@ alias ovp_join_prophunt "connect 127.0.0.1:27016"
alias ovp_join_deathrun "connect 127.0.0.1:27017"
alias ovp_join_fortwars "connect 127.0.0.1:27018"
alias ovp_join_traitortown "connect 127.0.0.1:27019"
++alias ovp_join_sandbox "connect 127.0.0.1:27020"
++
++// Sandbox Q menu (GMod-style spawn menu). Q sends the !q chat command, which the
++// server-side JS command registry intercepts and shows the spawn menu.
++bind q "say !q"
++echo "OpenVibe: Q bound to sandbox spawn menu (say !q)"
ddiff --git a/game/openvibe.games/cfg/openvibe_sandbox.cfg b/game/openvibe.games/cfg/openvibe_sandbox.cfg
nnew file mode 100644
iindex 0000000..e02198f
---- /dev/null
++++ b/game/openvibe.games/cfg/openvibe_sandbox.cfg
@@@ -0,0 +1,13 @@
++exec openvibe_common.cfg
++
++hostname "OpenVibe: Source | Sandbox Local"
++maxplayers 24
++
++// Sandbox uses the embedded JS "sandbox" gamemode (js/gamemodes/sandbox/).
++// Unlike the other dev cfgs, ov_mode is set (not commented) so LoadGamemode
++// pulls js/gamemodes/sandbox/server.js instead of defaulting to hub.
++ov_mode "sandbox"
++
++// No round timer in sandbox.
++mp_timelimit 0
++mp_fraglimit 0
ddiff --git a/game/openvibe.games/js/gamemodes/sandbox/client.js b/game/openvibe.games/js/gamemodes/sandbox/client.js
nnew file mode 100644
iindex 0000000..ab76bad
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/sandbox/client.js
@@@ -0,0 +1,6 @@
++// OpenVibe Sandbox — client realm.
++// The spawn menu is served by the server realm (server.js) and shown via chat.
++// The actual "press Q" binding is installed from the client cfg
++// (openvibe_proton_client.cfg: bind q "say !q"), which routes to the server's
++// !q command through the PlayerSay hook. This file just marks the realm loaded.
++console.log("OpenVibe Sandbox client realm loaded — press Q for the spawn menu.");
ddiff --git a/game/openvibe.games/js/gamemodes/sandbox/manifest.json b/game/openvibe.games/js/gamemodes/sandbox/manifest.json
nnew file mode 100644
iindex 0000000..1c90712
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/sandbox/manifest.json
@@@ -0,0 +1,10 @@
++{
++ "id": "sandbox",
++ "name": "OpenVibe Sandbox",
++ "base": "base",
++ "entry": {
++ "shared": "shared.js",
++ "server": "server.js",
++ "client": "client.js"
++ }
++}
ddiff --git a/game/openvibe.games/js/gamemodes/sandbox/server.js b/game/openvibe.games/js/gamemodes/sandbox/server.js
nnew file mode 100644
iindex 0000000..9bf8bca
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/sandbox/server.js
@@@ -0,0 +1,131 @@
++// OpenVibe Sandbox — server realm.
++//
++// Free-build sandbox mode with a GMod-style Q menu (spawn menu) implemented
++// entirely in the JS engine. There are no rounds: props can be spawned at any
++// time. The Q menu is a chat/console-driven spawn menu because the JS runtime
++// runs server-side; a keybind (bind q "ov_qmenu") forwards to the server which
++// prints the menu to the requesting player.
++(function () {
++ // The shared realm isn't auto-loaded by the C++ core loader (only
++ // base/server.js + <mode>/server.js are), so pull the catalog in defensively:
++ // if OVSandbox isn't defined yet, define a minimal fallback matching shared.js.
++ const catalog = (globalThis.OVSandbox && globalThis.OVSandbox.SPAWN_CATALOG) || [
++ {
++ category: "Build Props",
++ items: [
++ { id: "crate", label: "Wooden Crate" },
++ { id: "barrel", label: "Oil Drum" },
++ { id: "pallet", label: "Wood Pallet" },
++ { id: "fence", label: "Wood Fence" },
++ { id: "sheet", label: "Mattress" }
++ ]
++ }
++ ];
++
++ const spawnable = Object.create(null);
++ const numbered = [];
++ catalog.forEach(function (cat) {
++ cat.items.forEach(function (item) {
++ spawnable[item.id] = item.label;
++ numbered.push(item);
++ });
++ });
++
++ function ensureBuildEnabled() {
++ // ov_fortwars_spawn is gated behind this convar; sandbox keeps it on always.
++ OV.serverCommand("ov_fortwars_build_enabled 1");
++ }
++
++ function spawnProp(ply, id) {
++ const key = String(id || "").toLowerCase();
++ if (!spawnable[key]) {
++ if (ply) ply.chat(`Can't spawn "${id}". Type !q for the spawn menu.`);
++ return false;
++ }
++ if (!ply) return false;
++ ensureBuildEnabled();
++ ply.runCommand(`ov_fortwars_spawn ${key}`);
++ ply.chat(`Spawned: ${spawnable[key]}`);
++ return true;
++ }
++
++ // Render the Q menu to a single player as chat lines. Numbered so a player can
++ // spawn by number (!1) or by id (!spawn crate).
++ function openQMenu(ply) {
++ if (!ply) return;
++ ply.chat("=== OpenVibe Q Menu — Spawn ===");
++ let n = 0;
++ catalog.forEach(function (cat) {
++ ply.chat(`- ${cat.category} -`);
++ cat.items.forEach(function (item) {
++ n += 1;
++ ply.chat(` ${n}. ${item.label} (!spawn ${item.id} or !${n})`);
++ });
++ });
++ ply.chat("Spawn appears where you're looking.");
++ }
++
++ function registerCommands() {
++ if (!globalThis.command) return;
++
++ // Q menu open — chat (!q / !qmenu) and console (ov_qmenu via ConsoleCommand hook).
++ command.add("q", "Open the sandbox spawn (Q) menu", function ({ ply }) {
++ openQMenu(ply);
++ return false;
++ });
++ command.add("qmenu", "Open the sandbox spawn (Q) menu", function ({ ply }) {
++ openQMenu(ply);
++ return false;
++ });
++
++ // Spawn by id: !spawn crate
++ command.add("spawn", "Spawn a prop: !spawn <crate|barrel|pallet|fence|sheet>", function ({ ply, args }) {
++ spawnProp(ply, args[0] || "crate");
++ return false;
++ });
++
++ // Spawn by menu number: !1 .. !N
++ numbered.forEach(function (item, idx) {
++ const num = String(idx + 1);
++ command.add(num, `Spawn ${item.label}`, function ({ ply }) {
++ spawnProp(ply, item.id);
++ return false;
++ });
++ });
++ }
++
++ const GM = {
++ mode: "sandbox",
++ name: "OpenVibe Sandbox",
++
++ Initialize() {
++ OV.log("Sandbox Initialize fired");
++ registerCommands();
++ ensureBuildEnabled();
++ },
++
++ MapInitialize(mapName) {
++ OV.log(`Sandbox MapInitialize: ${mapName}`);
++ ensureBuildEnabled();
++ // Sandbox has no rounds — deliberately do NOT call scheduleRoundStart().
++ },
++
++ // No rounds in sandbox: neutralize the base round scheduler entirely.
++ scheduleRoundStart() {},
++ startRound() {},
++ endRound() {},
++
++ PlayerInitialSpawn(ply) {
++ ply.chat("Welcome to OpenVibe Sandbox! Press Q (or type !q) for the spawn menu.");
++ },
++
++ PlayerSpawn(ply) {
++ ensureBuildEnabled();
++ if (ply) ply.chat("Sandbox: !q spawn menu · !spawn crate");
++ },
++
++ Think() {}
++ };
++
++ gamemode.set(GM);
++})();
ddiff --git a/game/openvibe.games/js/gamemodes/sandbox/shared.js b/game/openvibe.games/js/gamemodes/sandbox/shared.js
nnew file mode 100644
iindex 0000000..f9f51d4
---- /dev/null
++++ b/game/openvibe.games/js/gamemodes/sandbox/shared.js
@@@ -0,0 +1,40 @@
++// OpenVibe Sandbox — shared realm.
++// The spawn catalog lives here so both the server (which does the actual
++// spawning) and the client (which can render the Q menu) agree on the exact
++// same categories and prop ids without duplicating the list.
++(function () {
++ // Every prop id here must be allowlisted by the C++ ov_fortwars_spawn command,
++ // which is what actually creates the prop_physics entity server-side.
++ const SPAWN_CATALOG = [
++ {
++ category: "Build Props",
++ items: [
++ { id: "crate", label: "Wooden Crate" },
++ { id: "barrel", label: "Oil Drum" },
++ { id: "pallet", label: "Wood Pallet" },
++ { id: "fence", label: "Wood Fence" },
++ { id: "sheet", label: "Mattress" }
++ ]
++ }
++ ];
++
++ // Flatten to a quick id -> label lookup for validation / menu numbering.
++ const byId = Object.create(null);
++ const flat = [];
++ SPAWN_CATALOG.forEach(function (cat) {
++ cat.items.forEach(function (item) {
++ byId[item.id] = item;
++ flat.push(item);
++ });
++ });
++
++ globalThis.OVSandbox = {
++ SPAWN_CATALOG: SPAWN_CATALOG,
++ spawnItems: flat,
++ isSpawnable: function (id) { return !!byId[String(id || "").toLowerCase()]; },
++ labelFor: function (id) {
++ const item = byId[String(id || "").toLowerCase()];
++ return item ? item.label : id;
++ }
++ };
++})();
ddiff --git a/tools/run-client-proton-x64.sh b/tools/run-client-proton-x64.sh
nnew file mode 100755
iindex 0000000..2bfa0c7
---- /dev/null
++++ b/tools/run-client-proton-x64.sh
@@@ -0,0 +1,68 @@
++#!/bin/bash
++# OpenVibe: Source - Game Client Launcher via GE-Proton10-34
++set -euo pipefail
++
++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
++STEAM_PATH="${OPENVIBE_STEAM_PATH:-/mnt/6tb/ssd_offload/home/workstation/.steam/debian-installation}"
++GE_PROTON="${OPENVIBE_GE_PROTON:-$STEAM_PATH/compatibilitytools.d/GE-Proton10-34}"
++STEAM_COMPAT_DATA="${OPENVIBE_STEAM_COMPAT_DATA:-$STEAM_PATH/steamapps/compatdata/243750}"
++HL2_EXE="${OPENVIBE_HL2_EXE:-/mnt/data-f/SteamLibrary/steamapps/common/Source SDK Base 2013 Multiplayer/hl2_win64.exe}"
++GAME_DIR="${OPENVIBE_GAME_DIR:-$ROOT/game/openvibe.games}"
++CLIENT_DLL="$GAME_DIR/bin/x64/client.dll"
++SERVER_DLL="$GAME_DIR/bin/x64/server.dll"
++
++if [ ! -f "$HL2_EXE" ]; then
++ echo "ERROR: hl2.exe not found at $HL2_EXE" >&2
++ exit 1
++fi
++
++if [ ! -d "$GE_PROTON" ]; then
++ echo "ERROR: GE-Proton not found at $GE_PROTON" >&2
++ exit 1
++fi
++
++CONNECT_ARGS=""
++if [ "${1:-}" != "" ] && [ "${2:-}" != "" ]; then
++ CONNECT_ARGS="+connect $1:$2"
++fi
++
++echo "Launching OpenVibe: Source via Proton..."
++echo " Game: $GAME_DIR"
++echo " Proton: $GE_PROTON"
++echo " HL2: $HL2_EXE"
++[ -n "$CONNECT_ARGS" ] && echo " Connect: $CONNECT_ARGS"
++
++if [ -f "$CLIENT_DLL" ] && command -v strings >/dev/null 2>&1 && strings -a "$CLIENT_DLL" | grep -Fq "ov_join"; then
++ echo " Client DLL: patched OpenVibe client.dll detected"
++elif [ -f "$CLIENT_DLL" ]; then
++ echo " WARNING: client.dll exists, but ov_join string was not found. It may be stock/old." >&2
++else
++ echo " WARNING: client.dll missing. Proton will not have OpenVibe client commands." >&2
++fi
++
++if [ ! -f "$SERVER_DLL" ]; then
++ echo " WARNING: server.dll missing for Windows listen/server use." >&2
++fi
++
++mkdir -p "$STEAM_COMPAT_DATA"
++
++export DISPLAY="${DISPLAY:-:0}"
++export STEAM_COMPAT_CLIENT_INSTALL_PATH="$STEAM_PATH"
++export STEAM_COMPAT_DATA_PATH="$STEAM_COMPAT_DATA"
++export SteamAppId="${SteamAppId:-243750}"
++export PROTON_LOG="${OPENVIBE_PROTON_LOG:-${PROTON_LOG:-1}}"
++
++# DXVK_ASYNC=1 races async shader pipeline compilation against DXVK swapchain
++# teardown on first launch; hl2.exe intermittently dies right after the first
++# frame with no error trace. Default off for reliability.
++export DXVK_ASYNC="${DXVK_ASYNC:-0}"
++
++exec "$GE_PROTON/proton" waitforexitandrun \
++ "$HL2_EXE" \
++ -game "$GAME_DIR" \
++ -console -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 \
++ $CONNECT_ARGS
ddiff --git a/tools/run-sandbox.sh b/tools/run-sandbox.sh
nnew file mode 100755
iindex 0000000..9b30b50
---- /dev/null
++++ b/tools/run-sandbox.sh
@@@ -0,0 +1,81 @@
++#!/usr/bin/env bash
++set -euo pipefail
++
++# OpenVibe Sandbox dev server (free-build + JS Q menu).
++#
++# Standalone launcher (does not use run-server.sh's fixed map_input) because the
++# sandbox needs to force the JS gamemode to "sandbox" AFTER the map is up:
++# loading the ov_hub map auto-execs cfg/ov_hub.cfg -> openvibe_hub.cfg, and the
++# JS runtime latches ov_mode on first tick. ov_js_reload re-reads ov_mode and
++# reloads the gamemode, so we set ov_mode + reload once the server is live.
++
++ROOT="${OPENVIBE_ROOT:-$HOME/src/openvibe-source}"
++SRCDS="${OPENVIBE_SRCDS:-$HOME/srcds/tf2}"
++MOD="$ROOT/game/openvibe.games"
++PORT="${OPENVIBE_SANDBOX_PORT:-27020}"
++MAP="${OPENVIBE_SANDBOX_MAP:-ov_hub}"
++MAXPLAYERS="${OPENVIBE_SANDBOX_MAXPLAYERS:-24}"
++BIND_IP="${OPENVIBE_BIND_IP:-127.0.0.1}"
++MAP_DELAY="${OPENVIBE_SRCDS_MAP_DELAY:-6}"
++RELOAD_DELAY="${OPENVIBE_SANDBOX_RELOAD_DELAY:-8}"
++
++if [[ ! -x "$SRCDS/srcds_linux64" ]]; then
++ echo "Missing 64-bit SRCDS at $SRCDS/srcds_linux64" >&2
++ exit 1
++fi
++if [[ ! -f "$MOD/maps/$MAP.bsp" ]]; then
++ echo "Missing map $MOD/maps/$MAP.bsp" >&2
++ exit 1
++fi
++
++cd "$SRCDS"
++printf '243750\n' > "$SRCDS/steam_appid.txt"
++
++COMPAT_LIB_DIR="${OPENVIBE_SRCDS_COMPAT_LIB_DIR:-/tmp/openvibe-srcds-compat-$USER}"
++if [[ ! -e /lib/i386-linux-gnu/libtinfo.so.5 && -e /lib/i386-linux-gnu/libtinfo.so.6 ]]; then
++ mkdir -p "$COMPAT_LIB_DIR"
++ ln -sfn /lib/i386-linux-gnu/libtinfo.so.6 "$COMPAT_LIB_DIR/libtinfo.so.5"
++fi
++if [[ ! -e /lib/i386-linux-gnu/libncurses.so.5 && -e /lib/i386-linux-gnu/libncurses.so.6 ]]; then
++ mkdir -p "$COMPAT_LIB_DIR"
++ ln -sfn /lib/i386-linux-gnu/libncurses.so.6 "$COMPAT_LIB_DIR/libncurses.so.5"
++fi
++
++export LD_LIBRARY_PATH="$COMPAT_LIB_DIR:$MOD/bin/linux64:.:$SRCDS/bin/linux64:$SRCDS/bin:${LD_LIBRARY_PATH:-}"
++
++server_cmd=(
++ ./srcds_linux64
++ -game "$MOD"
++ -console
++ -usercon
++ -ip "$BIND_IP"
++ -port "$PORT"
++ +clientport "$((PORT + 1000))"
++ +maxplayers "$MAXPLAYERS"
++ +tv_port "$((PORT + 2000))"
++ +exec openvibe_sandbox.cfg
++)
++
++printf -v server_command '%q ' "${server_cmd[@]}"
++
++console_input() {
++ sleep "$MAP_DELAY"
++ printf 'map %s\n' "$MAP"
++ # After the map is live and the JS runtime has started, force sandbox mode.
++ sleep "$RELOAD_DELAY"
++ printf 'ov_mode sandbox\n'
++ printf 'ov_js_reload\n'
++ while sleep 3600; do :; done
++}
++
++filter_output() {
++ sed -u \
++ -e '/WARNING: Failed to load 32-bit libtinfo\.so\.5 or libncurses\.so\.5\./d' \
++ -e '/Please install (lib32tinfo5 \/ ncurses-libs\.i686 \/ equivalent) to enable readline\./d'
++}
++
++if command -v script >/dev/null 2>&1; then
++ console_input | script -qfec "$server_command" /dev/null | filter_output
++else
++ console_input | "${server_cmd[@]}" | filter_output
++fi