added option for mouse acceleration and button to manually restart the game if stuck

This commit is contained in:
jenz 2026-07-24 10:57:53 +01:00
parent 19e7ff4f14
commit 62e74b740f
2 changed files with 151 additions and 38 deletions

View File

@ -81,6 +81,27 @@
accent-color: #4fc; accent-color: #4fc;
cursor: pointer; cursor: pointer;
} }
#sensitivity-control {
position: fixed;
bottom: 10px;
left: 10px;
background: rgba(0,0,0,0.7);
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
z-index: 10;
display: flex;
align-items: center;
gap: 8px;
color: #fff;
font-family: monospace;
}
#sensitivity-control input[type=range] {
width: 80px;
accent-color: #4fc;
cursor: pointer;
}
#sound-toggle { #sound-toggle {
position: fixed; position: fixed;
top: 10px; top: 10px;
@ -97,6 +118,22 @@
} }
#sound-toggle:hover { background: rgba(40,40,40,0.85); } #sound-toggle:hover { background: rgba(40,40,40,0.85); }
#sound-toggle.unmuted { color: #4fc; border-color: #4fc; } #sound-toggle.unmuted { color: #4fc; border-color: #4fc; }
#restart-game-btn {
position: fixed;
top: 36px;
left: 10px;
background: rgba(120,20,20,0.75);
color: #fff;
border: 1px solid #a44;
padding: 6px 12px;
border-radius: 4px;
font-size: 13px;
font-family: monospace;
cursor: pointer;
z-index: 10;
}
#restart-game-btn:hover { background: rgba(160,30,30,0.9); }
#gameCanvas { #gameCanvas {
width: 1280px; width: 1280px;
height: 720px; height: 720px;
@ -110,7 +147,7 @@
#queue-panel { #queue-panel {
position: fixed; position: fixed;
top: 40px; top: 68px;
left: 10px; left: 10px;
background: rgba(0,0,0,0.75); background: rgba(0,0,0,0.75);
padding: 10px 14px; padding: 10px 14px;
@ -165,9 +202,13 @@
<body> <body>
<div id="status">Connecting...</div> <div id="status">Connecting...</div>
<button id="sound-toggle">🔇 Unmute</button> <button id="sound-toggle">🔇 Unmute</button>
<button id="restart-game-btn" style="display:none;">🔄 Restart Game</button>
<div id="brightness-control"> <div id="brightness-control">
☀️ <input type="range" id="brightness-slider" min="50" max="200" value="100" step="5"> <span id="brightness-value">100%</span> ☀️ <input type="range" id="brightness-slider" min="50" max="200" value="100" step="5"> <span id="brightness-value">100%</span>
</div> </div>
<div id="sensitivity-control">
🖱️ <input type="range" id="sensitivity-slider" min="1" max="6" value="3" step="0.5"> <span id="sensitivity-value">1.0x</span>
</div>
<div id="idle-warning">Idle — control will be released soon. Move your mouse to stay in control.</div> <div id="idle-warning">Idle — control will be released soon. Move your mouse to stay in control.</div>
@ -263,8 +304,12 @@
const loadingEl = document.getElementById('loading-overlay'); const loadingEl = document.getElementById('loading-overlay');
const idleWarnEl = document.getElementById('idle-warning'); const idleWarnEl = document.getElementById('idle-warning');
const soundToggleEl = document.getElementById('sound-toggle'); const soundToggleEl = document.getElementById('sound-toggle');
const restartGameBtn = document.getElementById('restart-game-btn');
const brightnessSlider = document.getElementById('brightness-slider'); const brightnessSlider = document.getElementById('brightness-slider');
const brightnessValue = document.getElementById('brightness-value'); const brightnessValue = document.getElementById('brightness-value');
const sensitivitySlider = document.getElementById('sensitivity-slider');
const sensitivityValue = document.getElementById('sensitivity-value');
let mouseSensitivity = 1;
let sigWs = null; let sigWs = null;
let pc = null; let pc = null;
@ -273,6 +318,7 @@
let isControlling = false; let isControlling = false;
let isInQueue = false; let isInQueue = false;
let isWaitingForRestart = false; let isWaitingForRestart = false;
let waitingRetryTimer = null;
let idleWarnTimer = null; let idleWarnTimer = null;
function setStatus(msg) { statusEl.textContent = msg; } function setStatus(msg) { statusEl.textContent = msg; }
@ -311,12 +357,29 @@
setMuted(!canvas.muted); setMuted(!canvas.muted);
}); });
restartGameBtn.addEventListener('click', () => {
if (!isControlling) return;
const confirmed = confirm(
'Restart the game? This kills and relaunches Counter-Strike Source. ' +
'Use this only if the game appears stuck/frozen. Takes a few seconds.'
);
if (!confirmed) return;
if (sigWs && sigWs.readyState === WebSocket.OPEN) {
sigWs.send(JSON.stringify({ type: 'restart_css' }));
}
});
brightnessSlider.addEventListener('input', () => { brightnessSlider.addEventListener('input', () => {
const val = brightnessSlider.value; const val = brightnessSlider.value;
canvas.style.filter = `brightness(${val}%)`; canvas.style.filter = `brightness(${val}%)`;
brightnessValue.textContent = `${val}%`; brightnessValue.textContent = `${val}%`;
}); });
sensitivitySlider.addEventListener('input', () => {
mouseSensitivity = parseFloat(sensitivitySlider.value);
sensitivityValue.textContent = `${mouseSensitivity.toFixed(1)}x`;
});
function updateQueuePanel(controller, queue, restarting) { function updateQueuePanel(controller, queue, restarting) {
let html = ''; let html = '';
if (restarting) { if (restarting) {
@ -405,6 +468,7 @@
canvas.requestPointerLock(); canvas.requestPointerLock();
hintEl.textContent = 'Mouse captured — ESC to release'; hintEl.textContent = 'Mouse captured — ESC to release';
resetIdleWarning(); resetIdleWarning();
restartGameBtn.style.display = 'block';
} }
function exitControl() { function exitControl() {
@ -416,6 +480,7 @@
stopMic(); stopMic();
if (idleWarnTimer) clearTimeout(idleWarnTimer); if (idleWarnTimer) clearTimeout(idleWarnTimer);
idleWarnEl.classList.remove('visible'); idleWarnEl.classList.remove('visible');
restartGameBtn.style.display = 'none';
if (document.pointerLockElement === canvas) document.exitPointerLock(); if (document.pointerLockElement === canvas) document.exitPointerLock();
} }
@ -459,6 +524,7 @@
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'session_created') { if (msg.type === 'session_created') {
if (waitingRetryTimer) { clearInterval(waitingRetryTimer); waitingRetryTimer = null; }
sessionId = msg.sessionId; sessionId = msg.sessionId;
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdpAnswer)); await pc.setRemoteDescription(new RTCSessionDescription(msg.sdpAnswer));
await waitForIce(); await waitForIce();
@ -490,6 +556,21 @@
} }
else if (msg.type === 'waiting') { else if (msg.type === 'waiting') {
setStatus('Waiting for game instance...'); setStatus('Waiting for game instance...');
// Previously this just set the status text and did nothing further —
// if gst_agent had crashed (e.g. ICE failure) and hadn't finished
// restarting/re-registering yet at the exact moment this browser's
// browser_connect request landed, the browser was stuck showing
// this message forever with no way to recover short of a manual
// page refresh, even after gst_agent came back online moments later.
// Now retry browser_connect periodically until session_created
// arrives (cleared there) or the socket closes.
if (!waitingRetryTimer) {
waitingRetryTimer = setInterval(() => {
if (sigWs && sigWs.readyState === WebSocket.OPEN && pc && pc.localDescription) {
sigWs.send(JSON.stringify({ type: 'browser_connect', sdp: pc.localDescription.sdp }));
}
}, 3000);
}
} }
else if (msg.type === 'error') { else if (msg.type === 'error') {
setStatus('Error: ' + msg.message); setStatus('Error: ' + msg.message);
@ -500,6 +581,7 @@
sigWs.onclose = () => { sigWs.onclose = () => {
stopMic(); stopMic();
exitControl(); exitControl();
if (waitingRetryTimer) { clearInterval(waitingRetryTimer); waitingRetryTimer = null; }
setStatus('Disconnected'); setStatus('Disconnected');
}; };
} }
@ -566,7 +648,7 @@
const now = performance.now(); const now = performance.now();
if (now - lastMouseMove < 16) return; if (now - lastMouseMove < 16) return;
lastMouseMove = now; lastMouseMove = now;
sendInput({ type: 'mouse_move', dx: e.movementX, dy: e.movementY }); sendInput({ type: 'mouse_move', dx: e.movementX * mouseSensitivity, dy: e.movementY * mouseSensitivity });
pingActive(); pingActive();
}); });

View File

@ -64,10 +64,11 @@ app.use(express.urlencoded({ extended: false }));
app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html'))); app.get('/', (req, res) => res.sendFile(path.join(__dirname, '..', 'client.html')));
// ── Internal-request gate ───────────────────────────────────────────────────── // ── Internal-request gate ─────────────────────────────────────────────────────
// Used by both /webclient-name (POST, from the SourceMod plugin) and // Used by /webclient-name (POST), /webclient-current-ip (GET), and
// /webclient-current-ip (GET, polled by the SourceMod plugin). Restricted to // /webclient-force-restart (POST) — all internal-only endpoints intended for
// loopback / docker-bridge / private ranges, PLUS this box's own resolved // the SourceMod plugin (and, for restart, an optional safety-net trigger).
// public IP. // Restricted to loopback / docker-bridge / private ranges, PLUS this box's
// own resolved public IP.
// //
// The public-IP allowance is required because SteamWorks' embedded HTTP // The public-IP allowance is required because SteamWorks' embedded HTTP
// client (or the Source Engine networking layer beneath it) does NOT route // client (or the Source Engine networking layer beneath it) does NOT route
@ -121,15 +122,25 @@ app.post('/webclient-name', (req, res) => {
} }
const name = (req.body && req.body.name) ? String(req.body.name).trim() : ''; const name = (req.body && req.body.name) ? String(req.body.name).trim() : '';
const ip = (req.body && req.body.ip) ? String(req.body.ip).trim() : '';
if (!name) { if (!name) {
return res.status(400).send('missing name'); return res.status(400).send('missing name');
} }
// Only apply this name if it corresponds to the CURRENTLY active
// controller's real IP. Without this check, a delayed/stale POST from a
// previous webclient session (the connect extension's OnClientAuthorized
// can fire with a noticeable delay after the actual in-game reconnect)
// can land AFTER a new person has already taken over control, silently
// overwriting the new controller's correct name with the old one's.
if (!currentController || !ip || currentController.ws._realIp !== ip) {
console.log(`[sig] ignored stale /webclient-name (ip=${ip || 'none'}) — does not match current controller`);
return res.status(200).send('ignored (stale)');
}
console.log(`[sig] webclient-name received: ${name}`); console.log(`[sig] webclient-name received: ${name}`);
lastControllerName = name; lastControllerName = name;
if (currentController) {
currentController.visitorName = name; currentController.visitorName = name;
}
broadcastQueueState(); broadcastQueueState();
res.status(200).send('ok'); res.status(200).send('ok');
@ -138,21 +149,9 @@ app.post('/webclient-name', (req, res) => {
// SourceMod plugin -> signaling server (polled): returns the current // SourceMod plugin -> signaling server (polled): returns the current
// controller's real IP as plain text, so the plugin can detect when it // controller's real IP as plain text, so the plugin can detect when it
// changes and force the already-connected webclient player to reconnect // changes and force the already-connected webclient player to reconnect
// with "password <ip>; retry" — reusing the exact same connect-extension // via ClientCommand(client, "retry") — the connect extension backfills the
// path already proven correct when a password is supplied manually. // real password from this same cached IP via OnClientPreConnectEx.
// Returns an empty body when nobody is currently controlling. // Returns an empty body when nobody is currently controlling.
//
// This is the ONLY mechanism used to propagate the new controller's IP into
// the game now — there is no more GameMenu.res rewriting, no sed, no
// automated Escape/mouse-move/click sequence. That whole approach was
// removed: GameMenu.res is only parsed once by the engine at menu-build
// time, so rewriting the file on disk while CSS keeps running indefinitely
// (which we do, by design, to avoid restarting the game on every handoff)
// never actually took effect after the very first read — every subsequent
// person's reconnect silently reused whichever password was cached from
// that first parse, which is why everyone ended up with the same generated
// name/SteamID regardless of how many times the file was correctly
// rewritten on disk.
app.get('/webclient-current-ip', (req, res) => { app.get('/webclient-current-ip', (req, res) => {
if (!isInternalRequest(req)) { if (!isInternalRequest(req)) {
console.log(`[sig] rejected /webclient-current-ip from non-internal IP: ${req.socket.remoteAddress}`); console.log(`[sig] rejected /webclient-current-ip from non-internal IP: ${req.socket.remoteAddress}`);
@ -163,6 +162,22 @@ app.get('/webclient-current-ip', (req, res) => {
res.status(200).send(ip); res.status(200).send(ip);
}); });
// Internal-only safety net: force-kill and relaunch CSS on demand. Intended
// for use by the SourceMod plugin (or any other trusted internal caller) if
// it detects the game client itself has wedged/crashed and needs a hard
// restart, separate from the manual in-browser restart button and the
// scheduled 5-hour restart.
app.post('/webclient-force-restart', (req, res) => {
if (!isInternalRequest(req)) {
console.log(`[sig] rejected /webclient-force-restart from non-internal IP: ${req.socket.remoteAddress}`);
return res.status(403).send('forbidden');
}
console.log('[sig] /webclient-force-restart triggered');
performCssRestart('force-restart endpoint');
res.status(200).send('ok');
});
// ── Session registry ────────────────────────────────────────────────────────── // ── Session registry ──────────────────────────────────────────────────────────
const agentSessions = new Map(); const agentSessions = new Map();
const browserToAgent = new Map(); const browserToAgent = new Map();
@ -209,7 +224,7 @@ function applyController(entry) {
// Granting control is now instant — no CSS restart, no GameMenu.res // Granting control is now instant — no CSS restart, no GameMenu.res
// rewrite, no automated click sequence. The SourceMod plugin picks up the // rewrite, no automated click sequence. The SourceMod plugin picks up the
// new controller's IP by polling GET /webclient-current-ip and forces the // new controller's IP by polling GET /webclient-current-ip and forces the
// in-game reconnect itself via ClientCommand(client, "password %s; retry"). // in-game reconnect itself via ClientCommand(client, "retry").
lastControllerName = entry.visitorName; lastControllerName = entry.visitorName;
currentController = entry; currentController = entry;
resetIdleTimer(entry); resetIdleTimer(entry);
@ -222,37 +237,42 @@ function applyController(entry) {
} }
} }
// ── Periodic CSS restart ────────────────────────────────────────────────────── // ── CSS restart (shared by scheduled timer, manual button, and the internal
// Controller handoffs no longer restart the game at all, so CSS now stays // force-restart endpoint) ──────────────────────────────────────────────────
// running indefinitely across many sessions. Long Wine sessions have function performCssRestart(reason) {
// historically accumulated issues (audio/keyboard corruption, memory console.log(`[sig] CSS restart starting (${reason})`);
// growth), so restart the game process on a fixed schedule regardless of containerRestarting = true;
// who's currently in control. broadcastQueueState();
const CSS_RESTART_INTERVAL = 5 * 60 * 60 * 1000; // 5 hours
function scheduledCssRestart() {
console.log('[sig] scheduled 5-hour CSS restart starting');
try { try {
execSync(`docker exec ${CONTAINER} pkill -f "cstrike_win64.exe" || true`); execSync(`docker exec ${CONTAINER} pkill -f "cstrike_win64.exe" || true`);
execSync(`docker exec ${CONTAINER} pkill -f "revLoader.exe" || true`); execSync(`docker exec ${CONTAINER} pkill -f "revLoader.exe" || true`);
execSync(`docker exec ${CONTAINER} pkill -f "wineserver" || true`); execSync(`docker exec ${CONTAINER} pkill -f "wineserver" || true`);
execSync(`docker exec ${CONTAINER} pkill -f "winedevice.exe" || true`); execSync(`docker exec ${CONTAINER} pkill -f "winedevice.exe" || true`);
console.log('[sig] scheduled restart: CSS processes killed'); console.log('[sig] CSS restart: processes killed');
} catch (e) { } catch (e) {
console.error('[sig] scheduled restart: failed to kill CSS:', e.message); console.error('[sig] CSS restart: failed to kill CSS:', e.message);
} }
setTimeout(() => { setTimeout(() => {
try { try {
execSync(`docker exec -d ${CONTAINER} bash -c "DISPLAY=:99 wine '/home/ubuntu/Counter-Strike Source/revLoader.exe'"`); execSync(`docker exec -d ${CONTAINER} bash -c "DISPLAY=:99 wine '/home/ubuntu/Counter-Strike Source/revLoader.exe'"`);
console.log('[sig] scheduled restart: CSS relaunched'); console.log('[sig] CSS restart: relaunched');
} catch (e) { } catch (e) {
console.error('[sig] scheduled restart: failed to relaunch CSS:', e.message); console.error('[sig] CSS restart: failed to relaunch CSS:', e.message);
} }
containerRestarting = false;
broadcastQueueState();
}, 2000); }, 2000);
} }
setInterval(scheduledCssRestart, CSS_RESTART_INTERVAL); // Periodic restart: controller handoffs no longer restart the game at all,
// so CSS stays running indefinitely across many sessions. Long Wine
// sessions have historically accumulated issues (audio/keyboard corruption,
// memory growth, wedged connection state), so restart on a fixed schedule
// regardless of who's currently in control.
const CSS_RESTART_INTERVAL = 5 * 60 * 60 * 1000; // 5 hours
setInterval(() => performCssRestart('scheduled 5-hour restart'), CSS_RESTART_INTERVAL);
function releaseControl(entry, reason) { function releaseControl(entry, reason) {
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
@ -426,6 +446,17 @@ wss.on('connection', (ws, req) => {
} }
} }
else if (msg.type === 'restart_css') {
// Manual restart button — only the CURRENT controller may trigger
// this. Anyone else's message is silently ignored.
if (currentController && currentController.ws === ws) {
console.log(`[sig] manual CSS restart requested by ${currentController.visitorName}`);
performCssRestart(`manual button — ${currentController.visitorName}`);
} else {
console.log('[sig] restart_css ignored — sender is not the current controller');
}
}
else if (msg.type === 'mic_stop') { else if (msg.type === 'mic_stop') {
if (browserSessId) stopPacat(browserSessId); if (browserSessId) stopPacat(browserSessId);
} }