diff --git a/eslint.config.cjs b/eslint.config.cjs index e26c8cf..444ca82 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -22,7 +22,7 @@ module.exports = [ }, js.configs.recommended, { - files: ["server.js", "scripts/**/*.mjs"], + files: ["server.js", "server/**/*.cjs", "scripts/**/*.mjs"], languageOptions: { ecmaVersion: "latest", sourceType: "commonjs", @@ -45,7 +45,7 @@ module.exports = [ }, }, { - files: ["public/**/*.js"], + files: ["public/**/*.js", "public/**/*.mjs"], languageOptions: { ecmaVersion: "latest", sourceType: "module", diff --git a/package.json b/package.json index 254e197..c666515 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,9 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "check:server": "node --check server.js", - "check": "npm run check:server && npm run lint && npm run check:controls-docs", + "check": "npm run check:server && npm run lint && npm run check:controls-docs && npm run test:shot-limits", "check:controls-docs": "node scripts/check-controls-docs.mjs", + "test:shot-limits": "node scripts/test-shot-limits.mjs", "release:prepare": "node scripts/prepare-release.mjs", "release:check": "node scripts/check-release.mjs", "release:check:increment": "node scripts/check-tag-increment.mjs", diff --git a/public/client.js b/public/client.js index 5e9b271..9bcc448 100644 --- a/public/client.js +++ b/public/client.js @@ -46,6 +46,7 @@ import * as THREE from 'three'; import { OBJLoader } from 'three/addons/loaders/OBJLoader.js'; import { initXR, toggleXRSession, updateXRControllerInput, setNormalAnimationLoop, isXREnabled } from './webxr.js'; import { createVoiceManager } from './voice.js'; +import { normalizeShotSlotCount } from './shot-limits.mjs'; // FPS let fps = 0; @@ -4808,7 +4809,7 @@ function handleMotion(deltaTime) { const firePressed = (!isMobile && keys['Space']) || ((isMobile || isXREnabled() || isGamepadConnected()) && virtualInput.fire); const fireNow = performance.now(); if (firePressed && fireNow >= nextAllowedShotAt) { - const maxActiveShots = Number.isFinite(gameConfig?.SHOT_MAX_ACTIVE) ? gameConfig.SHOT_MAX_ACTIVE : 1; + const maxActiveShots = normalizeShotSlotCount(gameConfig?.SHOT_MAX_ACTIVE); if (getActiveProjectileCountForPlayer(myPlayerId) < maxActiveShots) { if (shoot()) { nextAllowedShotAt = fireNow + getShotReloadTimeMs(); diff --git a/public/hud.js b/public/hud.js index 54f74ad..343aaff 100644 --- a/public/hud.js +++ b/public/hud.js @@ -7,6 +7,8 @@ // hud.js - Handles HUD and debug display logic +import { normalizeShotSlotCount } from './shot-limits.mjs'; + const degreeBarRenderState = { canvas: null, controlBox: null, @@ -503,7 +505,7 @@ export function updateShotStatus({ myPlayerId, projectiles, gameConfig, now = Da const hud = getHudCanvasContext(shotStatusRenderState, 'shotStatus'); if (!hud || !myPlayerId || !gameConfig) return; const { canvas: shotStatus, controlBox, ctx } = hud; - const maxSlots = Math.max(1, Math.floor(gameConfig.SHOT_MAX_ACTIVE || 1)); + const maxSlots = normalizeShotSlotCount(gameConfig.SHOT_MAX_ACTIVE); const indicatorWidth = Math.max(18, Math.round(window.innerWidth / 50)); const indicatorHeight = Math.max(8, Math.round(window.innerHeight / 80)); const indicatorSpace = Math.max(2, Math.round(indicatorHeight / 10) + 2); diff --git a/public/shot-limits.mjs b/public/shot-limits.mjs new file mode 100644 index 0000000..fc075ab --- /dev/null +++ b/public/shot-limits.mjs @@ -0,0 +1,21 @@ +/* + * Copyright (C) 2025-2026 Tim Riker + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3). + * Source: https://github.com/timriker/bzo + * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html + */ + +// Keep client-side allocations bounded even when the server configuration +// arrives through an untrusted WebSocket payload. +export const MAX_SHOT_SLOTS = 64; + +export function normalizeShotSlotCount(value) { + const parsedValue = Number(value); + if (!Number.isSafeInteger(parsedValue) || parsedValue < 1) { + return 1; + } + if (parsedValue > MAX_SHOT_SLOTS) { + return MAX_SHOT_SLOTS; + } + return parsedValue; +} diff --git a/scripts/test-shot-limits.mjs b/scripts/test-shot-limits.mjs new file mode 100644 index 0000000..c3e96fe --- /dev/null +++ b/scripts/test-shot-limits.mjs @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { MAX_SHOT_SLOTS, normalizeShotSlotCount } from '../public/shot-limits.mjs'; + +const require = createRequire(import.meta.url); +const serverLimits = require('../server/shot-limits.cjs'); + +assert.equal(MAX_SHOT_SLOTS, 64); +assert.equal(serverLimits.MAX_SHOT_SLOTS, MAX_SHOT_SLOTS); +assert.equal(normalizeShotSlotCount(1), 1); +assert.equal(normalizeShotSlotCount(3), 3); +assert.equal(normalizeShotSlotCount('3'), 3); +assert.equal(normalizeShotSlotCount(MAX_SHOT_SLOTS), MAX_SHOT_SLOTS); +assert.equal(normalizeShotSlotCount(MAX_SHOT_SLOTS + 1), MAX_SHOT_SLOTS); +assert.equal(normalizeShotSlotCount(0), 1); +assert.equal(normalizeShotSlotCount(-1), 1); +assert.equal(normalizeShotSlotCount(1.5), 1); +assert.equal(normalizeShotSlotCount(Number.POSITIVE_INFINITY), 1); +assert.equal(normalizeShotSlotCount(Number.MAX_SAFE_INTEGER + 1), 1); +assert.equal(normalizeShotSlotCount(null), 1); +assert.equal(normalizeShotSlotCount(undefined), 1); + +for (const value of [1, 3, '3', MAX_SHOT_SLOTS, MAX_SHOT_SLOTS + 1, 0, -1, 1.5, Infinity, Number.MAX_SAFE_INTEGER + 1, null, undefined]) { + assert.equal( + serverLimits.normalizeShotSlotCount(value), + normalizeShotSlotCount(value), + `client/server normalization diverged for ${String(value)}` + ); +} + +console.log('Shot slot limit tests passed'); diff --git a/server.js b/server.js index b6c7426..cce1b97 100644 --- a/server.js +++ b/server.js @@ -10,6 +10,7 @@ const logPath = require('path').join(__dirname, 'server.log'); // Clear server.log on restart require('fs').writeFileSync(logPath, ''); const { WebSocketServer } = require('ws'); +const { normalizeShotSlotCount } = require('./server/shot-limits.cjs'); const path = require('path'); const fs = require('fs'); @@ -332,7 +333,7 @@ if (Number.isFinite(configShotCooldown) && configShotCooldown > 0) { const configShotMaxActive = Number(serverConfig.shotMaxActive); if (Number.isInteger(configShotMaxActive) && configShotMaxActive > 0) { - GAME_CONFIG.SHOT_MAX_ACTIVE = configShotMaxActive; + GAME_CONFIG.SHOT_MAX_ACTIVE = normalizeShotSlotCount(configShotMaxActive); } const configShotRadius = Number(serverConfig.shotRadius); diff --git a/server/shot-limits.cjs b/server/shot-limits.cjs new file mode 100644 index 0000000..81b3477 --- /dev/null +++ b/server/shot-limits.cjs @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2025-2026 Tim Riker + * Licensed under the GNU Affero General Public License v3.0 (AGPLv3). + * Source: https://github.com/timriker/bzo + * See LICENSE or https://www.gnu.org/licenses/agpl-3.0.html + */ + +const MAX_SHOT_SLOTS = 64; + +function normalizeShotSlotCount(value) { + const parsedValue = Number(value); + if (!Number.isSafeInteger(parsedValue) || parsedValue < 1) { + return 1; + } + if (parsedValue > MAX_SHOT_SLOTS) { + return MAX_SHOT_SLOTS; + } + return parsedValue; +} + +module.exports = { + MAX_SHOT_SLOTS, + normalizeShotSlotCount, +};