Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -45,7 +45,7 @@ module.exports = [
},
},
{
files: ["public/**/*.js"],
files: ["public/**/*.js", "public/**/*.mjs"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion public/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion public/hud.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

// hud.js - Handles HUD and debug display logic

import { normalizeShotSlotCount } from './shot-limits.mjs';

const degreeBarRenderState = {
canvas: null,
controlBox: null,
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 21 additions & 0 deletions public/shot-limits.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/*
* Copyright (C) 2025-2026 Tim Riker <timriker@gmail.com>
* 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;
}
31 changes: 31 additions & 0 deletions scripts/test-shot-limits.mjs
Original file line number Diff line number Diff line change
@@ -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');
3 changes: 2 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions server/shot-limits.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright (C) 2025-2026 Tim Riker <timriker@gmail.com>
* 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,
};
Loading